diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs index 5ef822c..3e11c2e 100644 --- a/dependency-cruiser.cjs +++ b/dependency-cruiser.cjs @@ -1,22 +1,43 @@ module.exports = { forbidden: [ + { + name: "no-circular", + severity: "error", + from: {}, + to: { circular: true }, + }, + { + name: "slices-must-use-public-api", + severity: "error", + from: { + path: "^src/(app|pages|features|entities|shared)/([^/]+)/", + }, + to: { + path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)", + pathNot: "^src/$1/$2/", + }, + }, { name: "shared-must-not-depend-on-layers", + severity: "error", from: { path: "^src/shared/" }, to: { path: "^src/(entities|features|pages|app)/" }, }, { name: "entities-must-not-depend-on-higher-layers", + severity: "error", from: { path: "^src/entities/" }, to: { path: "^src/(features|pages|app)/" }, }, { name: "features-must-not-depend-on-higher-layers", + severity: "error", from: { path: "^src/features/" }, to: { path: "^src/(pages|app)/" }, }, { name: "pages-must-not-depend-on-app", + severity: "error", from: { path: "^src/pages/" }, to: { path: "^src/app/" }, }, diff --git a/package.json b/package.json index 5c9c1af..91a657c 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "openapi:check": "node scripts/openapi-check.mjs", "hygiene:check": "node scripts/repository-hygiene-check.mjs", "knip": "knip", - "deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long", - "verify": "npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build", + "deps": "node --test scripts/dependency-cruiser-config.test.mjs && depcruise src --config dependency-cruiser.cjs --output-type err-long", + "bundle:check": "node --test scripts/bundle-budget.test.mjs && node scripts/bundle-budget.mjs", + "bundle:baseline": "npm run build && node scripts/bundle-budget.mjs baseline", + "verify": "npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build && npm run bundle:check", "prepare": "husky", "preview": "vite preview" }, diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs new file mode 100644 index 0000000..9aec207 --- /dev/null +++ b/scripts/bundle-budget.mjs @@ -0,0 +1,144 @@ +import { statSync } from "node:fs"; +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; + +function manifestEntries(manifest) { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + throw new Error("Vite manifest must be an object"); + } + return Object.entries(manifest); +} + +function assetFile(entry, entryName) { + if (!entry || typeof entry.file !== "string" || !entry.file.endsWith(".js")) { + throw new Error( + `Manifest entry '${entryName}' must reference a JavaScript file`, + ); + } + return entry.file; +} + +export function getInitialAssetFiles(manifest) { + const entries = manifestEntries(manifest); + const entryCandidates = entries.filter( + ([, entry]) => entry?.isEntry === true, + ); + if (entryCandidates.length !== 1) { + throw new Error("Vite manifest must contain exactly one JavaScript entry"); + } + + const files = []; + const visitedEntries = new Set(); + const visit = (entryName) => { + if (visitedEntries.has(entryName)) return; + visitedEntries.add(entryName); + const entry = manifest[entryName]; + if (!entry) + throw new Error(`Vite manifest is missing eager import '${entryName}'`); + files.push(assetFile(entry, entryName)); + for (const importedEntry of entry.imports ?? []) visit(importedEntry); + }; + + visit(entryCandidates[0][0]); + return files; +} + +export function measureAssets(files, getSize) { + return files.reduce((total, file) => { + const size = getSize(file); + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error(`Cannot measure bundle asset '${file}'`); + } + return total + size; + }, 0); +} + +export 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`, + ); + } +} + +export function getLazyRouteAssetSizes(manifest, getSize, baseline) { + const routeEntries = manifestEntries(manifest).filter( + ([, entry]) => + entry?.isDynamicEntry === true && typeof entry.src === "string", + ); + if (routeEntries.length === 0) { + throw new Error("Vite manifest must contain lazy route chunks"); + } + + const routeSizes = Object.fromEntries( + routeEntries + .map(([entryName, entry]) => [ + entry.src, + getSize(assetFile(entry, entryName)), + ]) + .sort(([left], [right]) => left.localeCompare(right)), + ); + if (!baseline) return routeSizes; + + for (const [route, size] of Object.entries(routeSizes)) { + if (!Number.isSafeInteger(baseline[route])) { + throw new Error(`Missing baseline for lazy route chunk '${route}'`); + } + if (size > baseline[route]) { + throw new Error( + `Lazy route chunk '${route}' is ${size} bytes; baseline is ${baseline[route]} bytes`, + ); + } + } + return routeSizes; +} + +const distributionDirectory = resolve("dist"); +const manifestPath = resolve(distributionDirectory, "bundle-manifest.json"); +const baselinePath = resolve("scripts/lazy-route-bundle-baseline.json"); + +async function loadJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +function getAssetSize(file) { + return statSync(resolve(distributionDirectory, file)).size; +} + +async function run(mode) { + const manifest = await loadJson(manifestPath); + const initialBytes = measureAssets( + getInitialAssetFiles(manifest), + getAssetSize, + ); + assertBundleBudget(initialBytes); + const baseline = + mode === "baseline" ? undefined : await loadJson(baselinePath); + const lazyRouteSizes = getLazyRouteAssetSizes( + manifest, + getAssetSize, + baseline, + ); + + if (mode === "baseline") { + await writeFile( + baselinePath, + `${JSON.stringify(lazyRouteSizes, null, "\t")}\n`, + ); + } + console.log(`Initial JavaScript payload: ${initialBytes} bytes`); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const mode = process.argv[2] ?? "check"; + if (mode !== "check" && mode !== "baseline") { + throw new Error("Usage: node scripts/bundle-budget.mjs [check|baseline]"); + } + await run(mode); +} diff --git a/scripts/bundle-budget.test.mjs b/scripts/bundle-budget.test.mjs new file mode 100644 index 0000000..82e1de9 --- /dev/null +++ b/scripts/bundle-budget.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertBundleBudget, + getInitialAssetFiles, + getLazyRouteAssetSizes, + INITIAL_BUNDLE_BUDGET_BYTES, + measureAssets, +} from "./bundle-budget.mjs"; + +const manifest = { + "index.html": { + file: "assets/entry.js", + isEntry: true, + imports: ["_shared"], + dynamicImports: ["src/pages/account/ui/AccountPage.tsx"], + }, + _shared: { file: "assets/shared.js", imports: ["_runtime"] }, + _runtime: { file: "assets/runtime.js" }, + "src/pages/account/ui/AccountPage.tsx": { + file: "assets/account.js", + isDynamicEntry: true, + src: "src/pages/account/ui/AccountPage.tsx", + }, + "src/app/routes/ProtectedLayout.tsx": { + file: "assets/protected-layout.js", + isDynamicEntry: true, + src: "src/app/routes/ProtectedLayout.tsx", + }, +}; + +const sizes = new Map([ + ["assets/entry.js", 200 * 1024], + ["assets/shared.js", 175 * 1024], + ["assets/runtime.js", 100 * 1024], + ["assets/account.js", 250 * 1024], + ["assets/protected-layout.js", 50 * 1024], +]); + +test("measures only the eager import graph of the entry bundle", () => { + const assets = getInitialAssetFiles(manifest); + assert.deepEqual(assets, [ + "assets/entry.js", + "assets/shared.js", + "assets/runtime.js", + ]); + assert.equal( + measureAssets(assets, (asset) => sizes.get(asset)), + 475 * 1024, + ); +}); + +test("rejects an initial bundle above the configured budget", () => { + assert.throws( + () => assertBundleBudget(INITIAL_BUNDLE_BUDGET_BYTES + 1), + /JavaScript payload/, + ); +}); + +test("rejects malformed manifests and lazy-route size regressions", () => { + assert.throws(() => getInitialAssetFiles({}), /entry/); + assert.deepEqual( + getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset)), + { + "src/app/routes/ProtectedLayout.tsx": 50 * 1024, + "src/pages/account/ui/AccountPage.tsx": 250 * 1024, + }, + ); + assert.throws( + () => + getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), { + "src/app/routes/ProtectedLayout.tsx": 50 * 1024, + "src/pages/account/ui/AccountPage.tsx": 249 * 1024, + }), + /route chunk/, + ); + assert.throws( + () => + getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), { + "src/pages/account/ui/AccountPage.tsx": 250 * 1024, + }), + /Missing baseline/, + ); +}); diff --git a/scripts/dependency-cruiser-config.test.mjs b/scripts/dependency-cruiser-config.test.mjs new file mode 100644 index 0000000..570c841 --- /dev/null +++ b/scripts/dependency-cruiser-config.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptsDirectory = dirname(fileURLToPath(import.meta.url)); +const projectDirectory = dirname(scriptsDirectory); +const dependencyCruiser = join( + projectDirectory, + "node_modules/dependency-cruiser/bin/dependency-cruise.mjs", +); +const dependencyConfig = join(projectDirectory, "dependency-cruiser.cjs"); + +async function withFixture(files, run) { + const fixtureDirectory = await mkdtemp(join(tmpdir(), "wealth-ledger-deps-")); + try { + await Promise.all( + Object.entries(files).map(async ([relativePath, contents]) => { + const filePath = join(fixtureDirectory, relativePath); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, contents); + }), + ); + return await run(fixtureDirectory); + } finally { + await rm(fixtureDirectory, { recursive: true, force: true }); + } +} + +function cruise(fixtureDirectory) { + return spawnSync( + process.execPath, + [ + dependencyCruiser, + "src", + "--config", + dependencyConfig, + "--output-type", + "err-long", + ], + { cwd: fixtureDirectory, encoding: "utf8" }, + ); +} + +function output(result) { + return `${result.stdout}${result.stderr}`; +} + +test("rejects a source import cycle", async () => { + await withFixture( + { + "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }), + "src/shared/i18n/index.ts": 'export { value } from "./zod";\n', + "src/shared/i18n/zod.ts": 'export { value } from ".";\n', + }, + (fixtureDirectory) => { + const result = cruise(fixtureDirectory); + assert.notEqual(result.status, 0, output(result)); + assert.match(output(result), /no-circular/); + }, + ); +}); + +test("rejects a deep import into another slice", async () => { + await withFixture( + { + "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }), + "src/features/auth/ui/Form.ts": 'import "../../../shared/i18n/zod";\n', + "src/shared/i18n/index.ts": "export {};\n", + "src/shared/i18n/zod.ts": "export {};\n", + }, + (fixtureDirectory) => { + const result = cruise(fixtureDirectory); + assert.notEqual(result.status, 0, output(result)); + assert.match(output(result), /slices-must-use-public-api/); + }, + ); +}); + +test("rejects an index.tsx import from another slice", async () => { + await withFixture( + { + "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }), + "src/features/auth/ui/Form.ts": 'import "../../../shared/i18n";\n', + "src/shared/i18n/index.tsx": "export {};\n", + }, + (fixtureDirectory) => { + const result = cruise(fixtureDirectory); + assert.notEqual(result.status, 0, output(result)); + assert.match(output(result), /slices-must-use-public-api/); + }, + ); +}); + +test("allows imports within a slice and through another slice public API", async () => { + await withFixture( + { + "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }), + "src/features/auth/model/local.ts": "export const local = true;\n", + "src/features/auth/ui/Form.ts": [ + 'import { local } from "../model/local";\n', + 'import { value } from "../../../shared/i18n";\n', + "void local;\n", + "void value;\n", + ].join(""), + "src/shared/i18n/index.ts": 'export { value } from "./zod";\n', + "src/shared/i18n/zod.ts": "export const value = true;\n", + }, + (fixtureDirectory) => { + const result = cruise(fixtureDirectory); + assert.equal(result.status, 0, output(result)); + }, + ); +}); diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json new file mode 100644 index 0000000..de340ba --- /dev/null +++ b/scripts/lazy-route-bundle-baseline.json @@ -0,0 +1,11 @@ +{ + "src/app/routes/ProtectedLayout.tsx": 3137, + "src/pages/account-events/index.ts": 4468, + "src/pages/account-operations/index.ts": 13861, + "src/pages/account/index.ts": 17018, + "src/pages/accounts/index.ts": 6174, + "src/pages/login/index.ts": 669, + "src/pages/not-found/index.ts": 850, + "src/pages/register/index.ts": 675, + "src/pages/settings/index.ts": 38876 +} diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index 5eabbcf..0d99aab 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -1,6 +1,7 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; import { MemoryRouter } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -10,6 +11,7 @@ import { AccountsPageError, AccountsPageLoading, } from "@/pages/accounts"; +import { server } from "@/test/server"; import { theme } from "../theme"; import { AppRoutes } from "./AppRoutes"; @@ -89,6 +91,30 @@ describe("AppRoutes", () => { ); }); + it("lazy-loads the protected shell for an authenticated accounts route", async () => { + server.use( + http.post("*/api/v1/auth/refresh", () => + HttpResponse.json({ + accessToken: "test-token", + user: { id: "user-1", email: "user@example.com" }, + }), + ), + ); + renderRoutes("/accounts"); + + await waitFor(() => + expect( + screen.getByRole("heading", { + level: 2, + name: "Нет инвестиционных счетов", + }), + ).toBeInTheDocument(), + ); + expect( + screen.getByRole("navigation", { name: "WealthLedger" }), + ).toBeInTheDocument(); + }); + it.each([ ["loading", AccountsPageLoading, "status"], ["error", AccountsPageError, "alert"], diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index 8c7bf2c..1ca6180 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -1,15 +1,48 @@ +import { Loader } from "@mantine/core"; +import { lazy, Suspense } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router"; import { useAuth } from "@/features/auth"; -import { AccountPage } from "@/pages/account"; -import { AccountEventsPage } from "@/pages/account-events"; -import { AccountOperationsPage } from "@/pages/account-operations"; -import { AccountsPage } from "@/pages/accounts"; -import { LoginPage } from "@/pages/login"; -import { NotFoundPage } from "@/pages/not-found"; -import { RegisterPage } from "@/pages/register"; -import { SettingsPage } from "@/pages/settings"; -import { AppShell } from "@/shared/ui"; -import { AppBreadcrumbs } from "../ui/AppBreadcrumbs"; + +const AccountPage = lazy(async () => { + const { AccountPage } = await import("@/pages/account"); + return { default: AccountPage }; +}); +const AccountEventsPage = lazy(async () => { + const { AccountEventsPage } = await import("@/pages/account-events"); + return { default: AccountEventsPage }; +}); +const AccountOperationsPage = lazy(async () => { + const { AccountOperationsPage } = await import("@/pages/account-operations"); + return { default: AccountOperationsPage }; +}); +const AccountsPage = lazy(async () => { + const { AccountsPage } = await import("@/pages/accounts"); + return { default: AccountsPage }; +}); +const LoginPage = lazy(async () => { + const { LoginPage } = await import("@/pages/login"); + return { default: LoginPage }; +}); +const NotFoundPage = lazy(async () => { + const { NotFoundPage } = await import("@/pages/not-found"); + return { default: NotFoundPage }; +}); +const RegisterPage = lazy(async () => { + const { RegisterPage } = await import("@/pages/register"); + return { default: RegisterPage }; +}); +const SettingsPage = lazy(async () => { + const { SettingsPage } = await import("@/pages/settings"); + return { default: SettingsPage }; +}); +const ProtectedLayout = lazy(async () => { + const { ProtectedLayout } = await import("./ProtectedLayout"); + return { default: ProtectedLayout }; +}); + +function LazyPage({ children }: { children: React.ReactNode }) { + return }>{children}; +} function Protected({ children }: { children: React.ReactNode }) { const { user, ready } = useAuth(); @@ -40,7 +73,9 @@ export function AppRoutes() { path="/login" element={ - + + + } /> @@ -48,30 +83,70 @@ export function AppRoutes() { path="/register" element={ - + + + } /> - } /> + + + } > - } /> - } /> + + + + } + /> + + + + } + /> } + element={ + + + + } /> } + element={ + + + + } + /> + + + + } /> - } /> - } /> + + + + } + /> ); } diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx new file mode 100644 index 0000000..e62bf97 --- /dev/null +++ b/src/app/routes/ProtectedLayout.tsx @@ -0,0 +1,6 @@ +import { AppBreadcrumbs } from "@/app/ui"; +import { AppShell } from "@/shared/ui"; + +export function ProtectedLayout() { + return } />; +} diff --git a/src/app/routes/index.ts b/src/app/routes/index.ts index 1ca058c..504c1bd 100644 --- a/src/app/routes/index.ts +++ b/src/app/routes/index.ts @@ -1 +1,2 @@ export { AppRoutes } from "./AppRoutes"; +export { RouteErrorBoundary } from "./RouteErrorBoundary"; diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx index b0b9fb4..3bf376c 100644 --- a/src/app/ui/App.tsx +++ b/src/app/ui/App.tsx @@ -1,8 +1,7 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter } from "react-router"; -import { AppRoutes } from "@/app/routes"; -import { RouteErrorBoundary } from "@/app/routes/RouteErrorBoundary"; +import { AppRoutes, RouteErrorBoundary } from "@/app/routes"; import { theme } from "@/app/theme"; import { AuthProvider } from "@/features/auth"; diff --git a/src/app/ui/index.ts b/src/app/ui/index.ts new file mode 100644 index 0000000..0f97dc8 --- /dev/null +++ b/src/app/ui/index.ts @@ -0,0 +1 @@ +export { AppBreadcrumbs } from "./AppBreadcrumbs"; diff --git a/src/features/auth/ui/AuthForm.tsx b/src/features/auth/ui/AuthForm.tsx index 233137d..9c761d0 100644 --- a/src/features/auth/ui/AuthForm.tsx +++ b/src/features/auth/ui/AuthForm.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { z } from "zod"; -import { validationMessage } from "../../../shared/i18n"; +import { validationMessage } from "@/shared/i18n"; function isEmailCharacterInvalid(character: string): boolean { return ( diff --git a/src/shared/i18n/i18n.ts b/src/shared/i18n/i18n.ts new file mode 100644 index 0000000..fee305d --- /dev/null +++ b/src/shared/i18n/i18n.ts @@ -0,0 +1,13 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; + +import { ru } from "./locales/ru"; + +void i18n.use(initReactI18next).init({ + resources: { ru }, + lng: "ru", + fallbackLng: "ru", + interpolation: { escapeValue: false }, +}); + +export { i18n }; diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts index 0ded77c..72970a0 100644 --- a/src/shared/i18n/index.ts +++ b/src/shared/i18n/index.ts @@ -1,14 +1 @@ -import i18n from "i18next"; -import { initReactI18next } from "react-i18next"; - -import { ru } from "./locales/ru"; - -void i18n.use(initReactI18next).init({ - resources: { ru }, - lng: "ru", - fallbackLng: "ru", - interpolation: { escapeValue: false }, -}); - export { validationMessage } from "./zod"; -export { i18n }; diff --git a/src/shared/i18n/zod.ts b/src/shared/i18n/zod.ts index 29b4851..b152e9b 100644 --- a/src/shared/i18n/zod.ts +++ b/src/shared/i18n/zod.ts @@ -1,6 +1,6 @@ import type { ParseKeys } from "i18next"; -import { i18n } from "."; +import { i18n } from "./i18n"; export function validationMessage( key: ParseKeys, diff --git a/vite.config.ts b/vite.config.ts index 4c8a6bc..82ab4fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,4 +7,5 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [react(), babel({ presets: [reactCompilerPreset()] })], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, + build: { manifest: "bundle-manifest.json" }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 48779e5..4e76acf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,14 +1,25 @@ +import { fileURLToPath, URL } from "node:url"; import react from "@vitejs/plugin-react"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, test: { environment: "jsdom", + exclude: [...configDefaults.exclude, "scripts/**/*.test.mjs"], setupFiles: "./src/test/setup.ts", - coverage: { reporter: ["text", "html"] }, + coverage: { + all: true, + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/test/**", "src/**/*.test.{ts,tsx}"], + reporter: ["text", "html"], + thresholds: { + statements: 82, + branches: 75, + functions: 75, + lines: 83, + }, + }, }, }); - -import { fileURLToPath, URL } from "node:url";