From d10e1dd1827094e6e52768f0ccd2ea060aef95a1 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 25 Aug 2026 15:33:20 +0300 Subject: [PATCH] chore: codify React component style --- AGENTS.md | 1 + biome.json | 28 ++++- docs/CONVENTIONS.md | 18 +++ scripts/bundle-budget.mjs | 18 ++- src/app/routes/AppRoutes.tsx | 25 ++-- src/app/routes/ProtectedLayout.tsx | 7 +- src/app/routes/RouteErrorBoundary.test.tsx | 17 ++- src/app/routes/RouteErrorBoundary.tsx | 31 +++-- src/app/theme.ts | 4 +- src/app/ui/App.tsx | 7 +- src/app/ui/AppBreadcrumbs.tsx | 37 +++--- .../api/useBrokerageOperations.test.tsx | 16 ++- .../api/useBrokerageOperations.ts | 20 ++-- src/entities/broker-operation/index.ts | 6 +- .../api/useBrokerageAccounts.test.tsx | 18 ++- .../api/useBrokerageAccounts.ts | 13 ++- src/entities/brokerage-account/index.ts | 2 +- .../brokerage-account/model/display.ts | 6 +- .../ui/BrokerageAccountList.tsx | 9 +- .../api/useBrokerageEvents.test.tsx | 18 ++- .../brokerage-event/api/useBrokerageEvents.ts | 6 +- .../portfolio/api/usePortfolio.test.tsx | 16 ++- src/entities/portfolio/api/usePortfolio.ts | 6 +- src/entities/portfolio/index.ts | 3 +- src/entities/portfolio/model/display.ts | 20 ++-- src/features/auth/model/auth.test.tsx | 44 ++++--- src/features/auth/model/auth.tsx | 11 +- src/features/auth/ui/AuthForm.tsx | 16 +-- src/features/auth/ui/LoginForm.tsx | 8 +- src/features/auth/ui/RegisterForm.tsx | 8 +- .../ui/TbankConnectionSection.tsx | 17 ++- .../ui/TbankConnectionSection.view.tsx | 18 ++- .../ui/TbankConnectionSection.vm.ts | 6 +- .../account-events/ui/AccountEventsPage.tsx | 47 ++++++-- .../ui/AccountOperationsPage.tsx | 17 ++- src/pages/account/ui/AccountPage.tsx | 8 +- src/pages/account/ui/AccountPage.view.tsx | 109 ++++++++++-------- src/pages/account/ui/AccountPage.vm.test.tsx | 8 +- src/pages/account/ui/AccountPage.vm.ts | 23 ++-- .../ui/components/AccountHeading.view.tsx | 13 ++- .../ui/components/CashBalances.view.tsx | 15 ++- .../components/PortfolioRefreshState.view.tsx | 17 +-- .../ui/components/PortfolioTotals.view.tsx | 15 ++- .../ui/components/PositionCard.view.tsx | 15 ++- .../ui/components/PositionLogo.view.tsx | 16 ++- .../ui/components/PositionSection.view.tsx | 16 ++- .../ui/components/RecentEvents.view.tsx | 23 +++- .../ui/components/RecentOperations.view.tsx | 15 ++- .../account/ui/components/Section.view.tsx | 17 +-- src/pages/accounts/ui/AccountsPage.tsx | 8 +- src/pages/accounts/ui/AccountsPage.view.tsx | 92 ++++++++------- src/pages/accounts/ui/AccountsPage.vm.ts | 4 +- src/pages/login/ui/LoginPage.tsx | 8 +- src/pages/not-found/ui/NotFoundPage.tsx | 8 +- src/pages/register/ui/RegisterPage.tsx | 8 +- src/pages/settings/ui/SettingsPage.tsx | 8 +- src/shared/api/authenticated-query.test.tsx | 18 ++- src/shared/api/authenticated-query.ts | 4 +- src/shared/api/client.ts | 18 ++- src/shared/i18n/locales/ru.ts | 4 +- src/shared/i18n/zod.ts | 4 +- src/shared/ui/AppShell.tsx | 15 ++- src/shared/ui/StateView.tsx | 15 +-- src/test/handlers.ts | 4 +- src/test/server.ts | 4 +- 65 files changed, 711 insertions(+), 365 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4425c28..1cea4dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, stri ## Enforceable boundaries - Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript. +- React component declaration, props и export style: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), часть исполняется Biome, часть является mandatory review convention. - FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`. - HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls. - UI states и VM/view boundary: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); container вызывает page VM hook, VM владеет data/state/derived presentation data, `*.view.tsx` получает props и рендерит. View files дробить по semantic responsibility, когда section/state можно назвать отдельно. diff --git a/biome.json b/biome.json index edfcbaf..17ef8b7 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,31 @@ }, "linter": { "rules": { - "preset": "recommended" + "preset": "recommended", + "nursery": { + "useReactFunctionComponentDefinition": { + "level": "error", + "options": { + "namedComponents": "arrowFunction" + } + } + }, + "style": { + "noDefaultExport": "error", + "useExportsLast": "error" + } } - } + }, + "overrides": [ + { + "includes": ["vite.config.ts", "vitest.config.ts"], + "linter": { + "rules": { + "style": { + "noDefaultExport": "off" + } + } + } + } + ] } diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index cbc9bbc..1cd06b3 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -11,6 +11,24 @@ - Зависимости используют exact versions; не добавлять semver ranges. - Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits. +## React component style + +Biome-enforced guardrails: + +- Named React components объявлять как const-valued components, а не function declarations. +- Не использовать default exports. +- Export statements держать после declarations тела module. + +Mandatory review conventions: + +- React components типизировать через `FC`: `const Component: FC = (props) => { ... }`; если props нет — `const Component: FC = () => { ... }`. +- Props описывать named type рядом с component либо в `Component.types.ts`, если type переиспользуется или file становится крупным. +- Не использовать inline props typing в component parameters. +- Для components только с `children` использовать `FC` без отдельного props alias. Если кроме `children` есть другие props, использовать named props type на основе `PropsWithChildren<{ ... }>`. +- Не destructure props в component parameters; принимать `props` и destructure внутри component body. +- Обычный module export surface сводить к одному нижнему named export block, когда это выражается читаемо без потери public API. +- Чистые barrel files должны использовать прямой re-export без промежуточного import: `export { Component } from "./Component";`. + ## File roles - `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view. diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 9aec207..8159c2a 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -3,7 +3,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; -export const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024; +const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024; function manifestEntries(manifest) { if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { @@ -21,7 +21,7 @@ function assetFile(entry, entryName) { return entry.file; } -export function getInitialAssetFiles(manifest) { +function getInitialAssetFiles(manifest) { const entries = manifestEntries(manifest); const entryCandidates = entries.filter( ([, entry]) => entry?.isEntry === true, @@ -46,7 +46,7 @@ export function getInitialAssetFiles(manifest) { return files; } -export function measureAssets(files, getSize) { +function measureAssets(files, getSize) { return files.reduce((total, file) => { const size = getSize(file); if (!Number.isSafeInteger(size) || size < 0) { @@ -56,7 +56,7 @@ export function measureAssets(files, getSize) { }, 0); } -export function assertBundleBudget(initialBytes) { +function assertBundleBudget(initialBytes) { if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) { throw new Error( `Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`, @@ -64,7 +64,7 @@ export function assertBundleBudget(initialBytes) { } } -export function getLazyRouteAssetSizes(manifest, getSize, baseline) { +function getLazyRouteAssetSizes(manifest, getSize, baseline) { const routeEntries = manifestEntries(manifest).filter( ([, entry]) => entry?.isDynamicEntry === true && typeof entry.src === "string", @@ -142,3 +142,11 @@ if ( } await run(mode); } + +export { + assertBundleBudget, + getInitialAssetFiles, + getLazyRouteAssetSizes, + INITIAL_BUNDLE_BUDGET_BYTES, + measureAssets, +}; diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index 1ca6180..ad96729 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -1,4 +1,5 @@ import { Loader } from "@mantine/core"; +import type { FC, PropsWithChildren } from "react"; import { lazy, Suspense } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router"; import { useAuth } from "@/features/auth"; @@ -40,11 +41,14 @@ const ProtectedLayout = lazy(async () => { return { default: ProtectedLayout }; }); -function LazyPage({ children }: { children: React.ReactNode }) { - return }>{children}; -} +const LazyPage: FC = (props) => { + const { children } = props; -function Protected({ children }: { children: React.ReactNode }) { + return }>{children}; +}; + +const Protected: FC = (props) => { + const { children } = props; const { user, ready } = useAuth(); const location = useLocation(); if (!ready) return null; @@ -57,15 +61,16 @@ function Protected({ children }: { children: React.ReactNode }) { replace /> ); -} +}; -function Public({ children }: { children: React.ReactNode }) { +const Public: FC = (props) => { + const { children } = props; const { user, ready } = useAuth(); if (!ready) return null; return user ? : children; -} +}; -export function AppRoutes() { +const AppRoutes: FC = () => { return ( } /> @@ -149,4 +154,6 @@ export function AppRoutes() { /> ); -} +}; + +export { AppRoutes }; diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx index e62bf97..310e2ff 100644 --- a/src/app/routes/ProtectedLayout.tsx +++ b/src/app/routes/ProtectedLayout.tsx @@ -1,6 +1,9 @@ +import type { FC } from "react"; import { AppBreadcrumbs } from "@/app/ui"; import { AppShell } from "@/shared/ui"; -export function ProtectedLayout() { +const ProtectedLayout: FC = () => { return } />; -} +}; + +export { ProtectedLayout }; diff --git a/src/app/routes/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary.test.tsx index 738b6a7..bdcc7e4 100644 --- a/src/app/routes/RouteErrorBoundary.test.tsx +++ b/src/app/routes/RouteErrorBoundary.test.tsx @@ -1,5 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import type { FC } from "react"; import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import { RouteErrorBoundary } from "./RouteErrorBoundary"; @@ -9,10 +10,11 @@ vi.mock("@/features/auth", () => ({ useAuth: () => ({ user: authenticated ? {} : null }), })); -function ThrowingRoute(): never { +const ThrowingRoute: FC = () => { throw new Error("test route failure"); -} -function HealthyRoute() { +}; + +const HealthyRoute: FC = () => { return (
{useLocation().pathname === "/healthy" ? ( @@ -22,9 +24,11 @@ function HealthyRoute() { )}
); -} -function RouteHarness() { +}; + +const RouteHarness: FC = () => { const navigate = useNavigate(); + return ( <> ); -} +}; -export function RouteErrorBoundary({ - children, -}: { - children: React.ReactNode; -}) { +const RouteErrorBoundary: FC = (props) => { + const { children } = props; const { pathname, search } = useLocation(); const { user } = useAuth(); + return ( {children} ); -} +}; + +export { RouteErrorBoundary }; diff --git a/src/app/theme.ts b/src/app/theme.ts index 4918b59..d9147c2 100644 --- a/src/app/theme.ts +++ b/src/app/theme.ts @@ -1,6 +1,6 @@ import { createTheme } from "@mantine/core"; -export const theme = createTheme({ +const theme = createTheme({ primaryColor: "blue", defaultRadius: "md", fontFamily: @@ -20,3 +20,5 @@ export const theme = createTheme({ ], }, }); + +export { theme }; diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx index 3bf376c..5aa149c 100644 --- a/src/app/ui/App.tsx +++ b/src/app/ui/App.tsx @@ -1,5 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { FC } from "react"; import { BrowserRouter } from "react-router"; import { AppRoutes, RouteErrorBoundary } from "@/app/routes"; import { theme } from "@/app/theme"; @@ -9,7 +10,7 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } }, }); -export function App() { +const App: FC = () => { return ( @@ -23,4 +24,6 @@ export function App() { ); -} +}; + +export { App }; diff --git a/src/app/ui/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs.tsx index 3b5e18d..8c0f076 100644 --- a/src/app/ui/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs.tsx @@ -1,11 +1,24 @@ +import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link, useMatch } from "react-router"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; type Crumb = { label: string; to?: string }; -function BreadcrumbTrail({ items }: { items: Crumb[] }) { +type BreadcrumbTrailProps = { + items: Crumb[]; +}; + +type AccountBreadcrumbsProps = { + accountId: string; + events: boolean; + operations: boolean; +}; + +const BreadcrumbTrail: FC = (props) => { + const { items } = props; const { t } = useTranslation(); + return ( ); -} +}; + +export { AppShell }; diff --git a/src/shared/ui/StateView.tsx b/src/shared/ui/StateView.tsx index 08a6e4e..b7fe0d9 100644 --- a/src/shared/ui/StateView.tsx +++ b/src/shared/ui/StateView.tsx @@ -1,5 +1,5 @@ import { Button, Loader, Paper, Stack, Text, Title } from "@mantine/core"; -import type { ReactNode } from "react"; +import type { FC, ReactNode } from "react"; type StateViewProps = { title: string; @@ -9,13 +9,8 @@ type StateViewProps = { error?: boolean; }; -export function StateView({ - title, - text, - action, - loading = false, - error = false, -}: StateViewProps) { +const StateView: FC = (props) => { + const { title, text, action, loading = false, error = false } = props; const role = loading ? "status" : error ? "alert" : undefined; return ( @@ -28,6 +23,6 @@ export function StateView({ ); -} +}; -export { Button }; +export { Button, StateView }; diff --git a/src/test/handlers.ts b/src/test/handlers.ts index 38ddb49..ed8724e 100644 --- a/src/test/handlers.ts +++ b/src/test/handlers.ts @@ -5,7 +5,7 @@ const authentication = { user: { id: "user-1", email: "user@example.com" }, }; -export const handlers = [ +const handlers = [ http.post("*/api/v1/auth/register", () => HttpResponse.json(authentication)), http.post("*/api/v1/auth/login", () => HttpResponse.json(authentication)), http.post("*/api/v1/auth/refresh", () => @@ -53,3 +53,5 @@ export const handlers = [ }), ), ]; + +export { handlers }; diff --git a/src/test/server.ts b/src/test/server.ts index 7b37f2a..d50e43c 100644 --- a/src/test/server.ts +++ b/src/test/server.ts @@ -1,4 +1,6 @@ import { setupServer } from "msw/node"; import { handlers } from "./handlers"; -export const server = setupServer(...handlers); +const server = setupServer(...handlers); + +export { server };