# Customers.tsx Memoize & Split Implementation Plan > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. **Goal:** Split `src/pages/Customers.tsx` (1,794 lines) into memoized sub-components under `src/components/customers/` and pure helpers under `src/lib/customers.ts`, exactly as was done for Appointments in the prior 2.4 Execution Memo — without changing any user-visible behavior. **Architecture:** Extract pure utilities (formatters, PB filter builder, vehicle-info parser) into a `src/lib/customers.ts` module. Extract every inline sub-component (skeleton, empty/error states, card, form modals, delete dialog, detail view) into individual files under `src/components/customers/`, each wrapped in `React.memo` where it receives props. Wire them back into the orchestrator `Customers.tsx` via a barrel `index.ts`. Keep the public type exports (`CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`) re-exported from `Customers.tsx` so the 5 existing external importers are untouched. **Tech Stack:** React 19, TypeScript, Tailwind v4, Vite 8, Vitest + jsdom. **Constraints (from AGENTS.md):** - No PocketBase schema/migration changes — this is a pure frontend refactor. - Modal changes follow the background-location overlay pattern — but this split does NOT change any modal behavior; the extracted modal components keep their exact same markup and `fixed inset-0` overlay style. - Do not touch existing tests in `src/store/__tests__` or `src/lib/__tests__`. - Run `npm run build && npm run lint && npm run test` after the final task. --- ## Reference: existing extraction pattern The proven pattern is in `src/components/appointments/` (created by the 2026-07-06 memo for Phase 2.4). Key conventions to reuse: - One component per file, `function FooImpl(...) { ... } export const Foo = memo(FooImpl)` for presentational row/card components. - Barrel `index.ts` re-exports. - `src/lib/appointments.ts` holds pure helpers; components import from there, the page imports helpers too. - Types stay where external consumers already import them from (the page file re-exports). --- ## File inventory (current — 1,794 lines) | Lines | What | Destination | |------:|------|-------------| | 1–25 | Imports | trimmed in orchestrator | | 27–92 | `CustomerRecord`, `VehicleRecord`, `RepairOrderRecord`, `QuoteRecord`, `CustomerWithVehicles` interfaces | stay in `Customers.tsx` (re-export) | | 96–122 | `formatVehicleLabel`, `vehicleKey`, `escapePbString`, `activeROFilterForCustomer` | `src/lib/customers.ts` | | 126–143 | `CustomersSkeleton` | `src/components/customers/EmptyStates.tsx` | | 145–185 | `EmptyState` | same file `EmptyStates.tsx` | | 187–189 | `ErrorState` | same file `EmptyStates.tsx` | | 193–270 | `CustomerCard` | `src/components/customers/CustomerCard.tsx` (memo) | | 274–441 | `CustomerFormData` interface + `CustomerFormModal` | interface stays in page re-export; component → `src/components/customers/CustomerFormModal.tsx` | | 444–668 | `VehicleFormData` interface + `VehicleFormModal` | interface → `src/lib/customers.ts` (or co-located); component → `src/components/customers/VehicleFormModal.tsx` | | 672–739 | `DeleteConfirmModal` | `src/components/customers/DeleteConfirmModal.tsx` | | 743–783 | `getRoStatusLabel`, `getRoStatusColor`, `formatDate` | `src/lib/customers.ts` | | 787–1170 | `CustomerDetailView` | `src/components/customers/CustomerDetailView.tsx` (memo) | | 1174–1794 | main `Customers` component | stays in `Customers.tsx` as orchestrator | ### External importers of `Customers.tsx` (must not break) - `src/components/CustomerPicker.tsx:4` → `import type { CustomerWithVehicles }` - `src/pages/Appointments.tsx:9` → `import type { CustomerRecord, CustomerWithVehicles }` - `src/components/appointments/CheckInModal.tsx:8` → `import type { CustomerWithVehicles }` - `src/components/appointments/ScanScreenshotModal.tsx:8` → `import type { CustomerWithVehicles }` - `src/components/appointments/AppointmentModal.tsx:10` → `import type { CustomerWithVehicles, CustomerFormData }` Resolution: `Customers.tsx` keeps `export interface CustomerRecord`, `export interface CustomerWithVehicles`, `export interface CustomerFormData` (re-export from a new `src/components/customers/types.ts` to avoid duplication — but re-export is enough; `import type` callers don't care where the symbol physically lives as long as the page re-exports it). Decision: create `src/components/customers/types.ts` holding the shared interfaces; `Customers.tsx` does `export type { CustomerRecord, CustomerWithVehicles, CustomerFormData, VehicleRecord, RepairOrderRecord, QuoteRecord } from '../components/customers/types'`. External importers keep working unchanged. --- ## Task 1: Create shared types module **Objective:** Move the shared interfaces out of the page into a dedicated types file so both the page and the new component files import from one place. **Files:** - Create: `src/components/customers/types.ts` - Modify: `src/pages/Customers.tsx` (replace the inline `interface` declarations with a re-export) **Step 1:** Create `src/components/customers/types.ts`: ```ts export interface CustomerRecord { id: string; name: string; phone: string; email: string; address: string; notes: string; userId: string; created: string; updated: string; } export interface VehicleRecord { id: string; customerId: string; userId?: string; make: string; model: string; year: string; vin: string; licensePlate: string; mileage: string; color: string; engine: string; } export interface RepairOrderRecord { id: string; roNumber: string; customerId: string; customerName: string; vehicleInfo: string; vin: string; mileage: string; status: string; workStatus: string; createdAt: string; completedTime: string; writeupTime: string; technician: string; notes: string; services: string; total: number; } export interface QuoteRecord { id: string; customerId: string; customerName: string; vehicleInfo: string; vin: string; mileage: string; status: string; createdAt: string; total: number; serviceAdvisor: string; services: string; notes: string; } export interface CustomerWithVehicles extends CustomerRecord { vehicles: VehicleRecord[]; vehicleCount: number; } export interface CustomerFormData { name: string; phone: string; email: string; address: string; notes: string; } export interface VehicleFormData { make: string; model: string; year: string; vin: string; licensePlate: string; mileage: string; color: string; engine: string; } ``` **Step 2:** In `src/pages/Customers.tsx`, delete lines 27–92 (the inline interface declarations) and replace with: ```ts export type { CustomerRecord, CustomerWithVehicles, CustomerFormData } from '../components/customers/types'; ``` Also add at the top, after the existing imports: ```ts import type { VehicleRecord, RepairOrderRecord, QuoteRecord, CustomerWithVehicles, CustomerFormData, VehicleFormData } from '../components/customers/types'; ``` Keep the `export` on `CustomerRecord` so existing importers don't break — verify the line above re-exports all three externally used types: `CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`. **Step 3 — Verify build:** ```bash npm run build ``` Expected: PASS (tsc + vite, 0 errors). The type-only changes don't affect runtime. **Step 4 — Commit:** ```bash git add src/components/customers/types.ts src/pages/Customers.tsx git commit -m "refactor(customers): extract shared interfaces to types module" ``` --- ## Task 2: Create `src/lib/customers.ts` (pure helpers) **Objective:** Move all pure functions out of the page so they can be shared by the new component files without a circular dependency. **Files:** - Create: `src/lib/customers.ts` **Step 1:** Create `src/lib/customers.ts` with these functions moved verbatim from `src/pages/Customers.tsx`: ```ts import type { VehicleRecord, RepairOrderRecord } from '../components/customers/types'; export function formatVehicleLabel(v: VehicleRecord): string { const parts = [v.year, v.make, v.model].filter(Boolean); return parts.length > 0 ? parts.join(' ') : 'Unknown vehicle'; } export function vehicleKey(v: Pick): string { return (v.vin || [v.year, v.make, v.model].filter(Boolean).join(' ')).trim().toLowerCase(); } export function escapePbString(value: string): string { return value.replace(/'/g, "\\'"); } export function activeROFilterForCustomer(userId: string, customerId: string, previousName: string): string { const activeStatuses = [ "status = 'active'", "status = 'in_progress'", "status = 'waiting_parts'", "status = 'waiting_pickup'", ].join(' || '); const identityFilters = [ `customerId = '${escapePbString(customerId)}'`, previousName.trim() ? `customerName = '${escapePbString(previousName.trim())}'` : '', ].filter(Boolean).join(' || '); return `userId = '${escapePbString(userId)}' && (${identityFilters}) && (${activeStatuses})`; } export function getRoStatusLabel(ro: RepairOrderRecord): string { const s = ro.workStatus || ro.status || ''; switch (s) { case 'active': case 'open': return 'Open'; case 'in-progress': case 'in_progress': return 'In Progress'; case 'waiter': case 'waiting_parts': return 'Waiting Parts'; case 'completed': return 'Completed'; case 'final_close': case 'delivered': return 'Final Close'; case 'cancelled': return 'Cancelled'; default: return s || 'Unknown'; } } export function getRoStatusColor(ro: RepairOrderRecord): string { const s = ro.workStatus || ro.status || ''; switch (s) { case 'active': case 'open': return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'; case 'in-progress': case 'in_progress': return 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'; case 'waiter': case 'waiting_parts': return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'; case 'completed': case 'final_close': case 'delivered': return 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'; case 'cancelled': return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'; default: return 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'; } } export function formatCustomerDate(dateStr: string): string { if (!dateStr) return '\u2014'; try { const d = new Date(dateStr); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return dateStr; } } export function parseQuoteServices(raw: unknown): { name: string; price: number; customerDecision: string }[] { if (!raw) return []; let items = raw; if (typeof raw === 'string') { try { items = JSON.parse(raw); } catch { return []; } } if (!Array.isArray(items)) return []; return items.map((s: any) => ({ name: s.name || 'Service', price: typeof s.price === 'number' ? s.price : 0, customerDecision: s.customerDecision || 'pending', })); } ``` Note: `formatDate` is renamed to `formatCustomerDate` to avoid a name clash with `src/lib/format.ts` exports. The detail view uses it locally; the move forces a rename. Keep the call-site reference updated in Task 7. **Step 2 — Verify build:** ```bash npm run build ``` Expected: FAIL — because `Customers.tsx` still has the old inline definitions; that's fine, we'll remove them in Task 8. For now just confirm `src/lib/customers.ts` compiles in isolation by running: ```bash npx tsc --noEmit src/lib/customers.ts ``` If tsc complains about project settings, skip this sub-step — the final build happens at the end. **Step 3 — Commit:** ```bash git add src/lib/customers.ts git commit -m "refactor(customers): add pure helpers module" ``` --- ## Task 3: Create `src/components/customers/EmptyStates.tsx` **Objective:** Extract the three state-display components (skeleton, empty, error) into one file (same pattern as `src/components/appointments/EmptyStates.tsx`). **Files:** - Create: `src/components/customers/EmptyStates.tsx` **Step 1:** Create the file, moving the JSX verbatim from `Customers.tsx` lines 126–189: ```tsx import { Search, Users, UserPlus } from 'lucide-react'; import { ListError } from '../ui/ListError'; export function CustomersSkeleton() { return (
{Array.from({ length: 6 }).map((_, i) => (
))}
); } export function EmptyState({ searchTerm, onCreate }: { searchTerm: string; onCreate: () => void }) { if (searchTerm) { return (

No matching customers

No customer records matched “{searchTerm}”.

Try searching by customer name, phone number, email address, vehicle, or VIN.

); } return (

No customer records on file

Add your first customer record to keep contact details, vehicles, quotes, and repair history organized in one place.

); } export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { return ; } ``` **Step 2 — Commit:** ```bash git add src/components/customers/EmptyStates.tsx git commit -m "refactor(customers): extract EmptyStates components" ``` --- ## Task 4: Create `src/components/customers/CustomerCard.tsx` **Objective:** Extract the card used in the list grid, wrapped in `React.memo`. **Files:** - Create: `src/components/customers/CustomerCard.tsx` **Step 1:** Create: ```tsx import { memo } from 'react'; import { Phone, Mail, Car, FileText, Pencil, Trash2 } from 'lucide-react'; import { formatPhoneDisplay } from '../../lib/phone'; import { formatVehicleLabel } from '../../lib/customers'; import type { CustomerWithVehicles } from './types'; interface Props { customer: CustomerWithVehicles; onView: (id: string) => void; onEdit: (c: CustomerWithVehicles) => void; onDelete: (c: CustomerWithVehicles) => void; } function CustomerCardImpl({ customer, onView, onEdit, onDelete }: Props) { return (

onView(customer.id)} > {customer.name}

ID: {customer.id.slice(0, 8)}

{customer.phone && ( {formatPhoneDisplay(customer.phone)} )} {customer.email && ( {customer.email} )}
{customer.vehicleCount === 0 ? 'No vehicles' : `${customer.vehicleCount} vehicle${customer.vehicleCount !== 1 ? 's' : ''}`} {customer.vehicles.length > 0 && ( — {formatVehicleLabel(customer.vehicles[0])} )}
); } export const CustomerCard = memo(CustomerCardImpl); ``` **Step 2 — Commit:** ```bash git add src/components/customers/CustomerCard.tsx git commit -m "refactor(customers): extract memoized CustomerCard" ``` --- ## Task 5: Create `src/components/customers/CustomerFormModal.tsx` **Objective:** Extract the Add/Edit Customer form modal. **It is NOT wrapped in `memo`** — it holds its own state (`useState`) and re-renders only when its `open` prop flips, so memo would add nothing. Keep the exact same JSX and behavior. **Files:** - Create: `src/components/customers/CustomerFormModal.tsx` **Step 1:** Create the file moving `CustomerFormModal` (lines 282–441 of `Customers.tsx`) verbatim. Replace the inline `CustomerFormData` type with an import: ```tsx import { useState, useEffect } from 'react'; import { X } from 'lucide-react'; import { formatPhoneInput } from '../../lib/phone'; import type { CustomerFormData, CustomerWithVehicles } from './types'; export function CustomerFormModal({ open, customer, onClose, onSave, }: { open: boolean; customer: CustomerWithVehicles | null; onClose: () => void; onSave: (data: CustomerFormData) => Promise; }) { // ... identical body to current lines 293–440 ... } ``` Copy the JSX body verbatim (lines 293–440 of `Customers.tsx`). **Step 2 — Commit:** ```bash git add src/components/customers/CustomerFormModal.tsx git commit -m "refactor(customers): extract CustomerFormModal" ``` --- ## Task 6: Create `src/components/customers/VehicleFormModal.tsx` and `DeleteConfirmModal.tsx` **Objective:** Extract the vehicle form and the delete-confirmation modal into their own files. **Files:** - Create: `src/components/customers/VehicleFormModal.tsx` - Create: `src/components/customers/DeleteConfirmModal.tsx` **Step 1:** Create `VehicleFormModal.tsx` — move lines 456–668 of `Customers.tsx` verbatim, importing `VehicleFormData` and `VehicleRecord` from `./types`: ```tsx import { useState, useEffect } from 'react'; import { X } from 'lucide-react'; import type { VehicleFormData, VehicleRecord } from './types'; export function VehicleFormModal({ open, vehicle, customerName, onClose, onSave, }: { open: boolean; vehicle: VehicleRecord | null; customerName: string; onClose: () => void; onSave: (data: VehicleFormData) => Promise; }) { // ... identical body to current lines 469–667 ... } ``` **Step 2:** Create `DeleteConfirmModal.tsx` — move lines 672–739 verbatim: ```tsx import { useState } from 'react'; import { AlertTriangle } from 'lucide-react'; export function DeleteConfirmModal({ open, customerName, onClose, onConfirm, }: { open: boolean; customerName: string; onClose: () => void; onConfirm: () => Promise; }) { // ... identical body to current lines 683–738 ... } ``` **Step 3 — Commit:** ```bash git add src/components/customers/VehicleFormModal.tsx src/components/customers/DeleteConfirmModal.tsx git commit -m "refactor(customers): extract VehicleFormModal and DeleteConfirmModal" ``` --- ## Task 7: Create `src/components/customers/CustomerDetailView.tsx` **Objective:** Extract the large detail view (lines 787–1170) into its own memoized component. It uses `getRoStatusLabel`, `getRoStatusColor`, `formatDate`, `parseQuoteServices` — all from `src/lib/customers.ts`. `formatDate` is renamed to `formatCustomerDate` in the helpers module to avoid a clash with `src/lib/format.ts`. **Files:** - Create: `src/components/customers/CustomerDetailView.tsx` **Step 1:** Create: ```tsx import { useState, memo } from 'react'; import { Phone, Mail, MapPin, ChevronLeft, Pencil, Plus, FileText, Car, Trash2 } from 'lucide-react'; import { formatPhoneDisplay } from '../../lib/phone'; import { formatVehicleLabel, getRoStatusLabel, getRoStatusColor, formatCustomerDate, parseQuoteServices, } from '../../lib/customers'; import type { CustomerWithVehicles, VehicleRecord, RepairOrderRecord, QuoteRecord, } from './types'; interface Props { customer: CustomerWithVehicles; vehicles: VehicleRecord[]; repairOrders: RepairOrderRecord[]; repairOrdersLoading: boolean; quotes: QuoteRecord[]; quotesLoading: boolean; onBack: () => void; onEdit: (c: CustomerWithVehicles) => void; onAddVehicle: () => void; onEditVehicle: (v: VehicleRecord) => void; onDeleteVehicle: (v: VehicleRecord) => void; } function CustomerDetailViewImpl({ customer, vehicles, repairOrders, repairOrdersLoading, quotes, quotesLoading, onBack, onEdit, onAddVehicle, onEditVehicle, onDeleteVehicle, }: Props) { const [expandedQuoteId, setExpandedQuoteId] = useState(null); const toggleQuote = (id: string) => setExpandedQuoteId(expandedQuoteId === id ? null : id); function formatCurrency(n: number): string { return '$' + n.toFixed(2); } return ( // ... copy JSX verbatim from lines 837–1168, replacing formatDate(...) calls // with formatCustomerDate(...) calls, and the inline parseQuoteServices with // the imported parseQuoteServices ... ); } export const CustomerDetailView = memo(CustomerDetailViewImpl); ``` **Step 2 — Commit:** ```bash git add src/components/customers/CustomerDetailView.tsx git commit -m "refactor(customers): extract memoized CustomerDetailView" ``` --- ## Task 8: Create `src/components/customers/index.ts` barrel **Objective:** One import line in the orchestrator. **Files:** - Create: `src/components/customers/index.ts` **Step 1:** ```ts export { CustomersSkeleton, EmptyState, ErrorState } from './EmptyStates'; export { CustomerCard } from './CustomerCard'; export { CustomerFormModal } from './CustomerFormModal'; export { VehicleFormModal } from './VehicleFormModal'; export { DeleteConfirmModal } from './DeleteConfirmModal'; export { CustomerDetailView } from './CustomerDetailView'; export type { CustomerRecord, VehicleRecord, RepairOrderRecord, QuoteRecord, CustomerWithVehicles, CustomerFormData, VehicleFormData, } from './types'; ``` **Step 2 — Commit:** ```bash git add src/components/customers/index.ts git commit -m "refactor(customers): add barrel index" ``` --- ## Task 9: Rewrite `Customers.tsx` as the orchestrator **Objective:** Remove every inline component and helper. Wire the extracted pieces. The main `Customers` component keeps all state, handlers, and the `renderListView`/`renderDetailView` orchestration. All handlers passed to memoized children are already wrapped in `useCallback`, so the memo is effective. **Files:** - Modify (rewrite): `src/pages/Customers.tsx` **Step 1:** Replace the top of the file (imports + old interface/helper/component declarations) with: ```tsx import { useState, useEffect, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { X, Plus } from 'lucide-react'; import { pb } from '../lib/pocketbase'; import { useToast } from '../components/ui/Toast'; import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages'; import { customerWriteSchema } from '../schemas/customer'; import { formatVehicleLabel, vehicleKey, escapePbString, activeROFilterForCustomer, } from '../lib/customers'; import { CustomersSkeleton, EmptyState, ErrorState, CustomerCard, CustomerFormModal, VehicleFormModal, DeleteConfirmModal, CustomerDetailView, } from '../components/customers'; export type { CustomerRecord, CustomerWithVehicles, CustomerFormData, } from '../components/customers/types'; import type { CustomerWithVehicles, CustomerRecord, VehicleRecord, RepairOrderRecord, QuoteRecord, CustomerFormData, VehicleFormData, } from '../components/customers/types'; ``` **Step 2:** Keep the main `Customers` function body (lines 1174–1794) intact, with these adjustments: - Remove the now-extracted `CustomerCard`, `CustomerFormModal`, `VehicleFormModal`, `DeleteConfirmModal`, `CustomerDetailView` usages in the footer modals — replace with imported versions (same JSX, different source). - Every handler (`fetchAll`, `handleViewCustomer`, `handleAddCustomer`, `handleEditCustomer`, `handleDeleteCustomer`, `handleAddVehicle`, `handleEditVehicle`, `handleDeleteVehicle`) is already wrapped in `useCallback` — keep them. - In `renderListView`, `` now refers to the memoized import. - In `renderDetailView`, `` refers to the memoized import. - In the modals footer, ``, ``, `` refer to the imports. - The `formatVehicleLabel` reference in the footer's "Delete Vehicle Confirmation" modal (line 1788: `formatVehicleLabel(deletingVehicle)`) now uses the import from `../lib/customers`. **Step 3 — Verify build:** ```bash npm run build ``` Expected: PASS (0 errors). The orchestrator should be ~520–580 lines (state + handlers + two render functions + modals footer). **Step 4 — Lint:** ```bash npm run lint ``` Expected: PASS (0 new errors/warnings; the 2 pre-existing Toast.tsx / Settings.tsx warnings remain). **Step 5 — Test:** ```bash npm run test ``` Expected: the full Vitest suite passes (86+ tests across 12+ files). No new tests are added in this refactor — there are no existing tests for `Customers.tsx` to break. The existing `src/schemas/__tests__/customer.test.ts` (if any) and `src/lib/__tests__/` and `src/store/__tests__/` suites must pass unchanged. **Step 6 — Commit:** ```bash git add src/pages/Customers.tsx git commit -m "refactor(customers): rewrite orchestrator using extracted components" ``` --- ## Task 10: Verification & bundle-size check **Objective:** Confirm the refactor produced a smaller Customers chunk and nothing else regressed. **Step 1 — Full verification block:** ```bash npm run build && npm run lint && npm run test ``` Expected: all three pass. **Step 2 — Inspect chunk sizes (same command from roadmap Appendix A):** ```bash du -sh dist/assets/ ls -laS dist/assets/ | head -10 for f in dist/assets/*.js; do printf "%-45s raw=%7d gzip=%7d\n" "$(basename $f)" "$(stat -c%s $f)" "$(gzip -c $f | wc -c)" done ``` Expected: the Customers chunk is now smaller (the detail-view and form modal code is split into smaller lazy-ish chunks, though Vite may or may not split these into separate chunks without explicit `lazy()` — at minimum the page source file is smaller, which improves maintainability and re-render hotspots as described in roadmap 2.4). **Step 3 — Manual smoke (optional):** No behavior change is expected. If the user wants to verify, `npm run dev` and click through: list → search → customer card → detail view → add/edit customer modal → add/edit vehicle modal → delete customer modal. All UI should look identical. **Step 4 — Commit (if any verification fixes):** If any verification step required fixes, commit those. Otherwise skip this step. --- ## Risks, tradeoffs, and open questions **Risks:** 1. **External type imports break.** Five files import types from `'../pages/Customers'`. Mitigation: `Customers.tsx` re-exports all three types. Verified in Task 1's re-export line. If any external file still breaks, update its import to `'../components/customers'` (or `../../components/customers/types`) — but the re-export means no change should be needed. 2. **`formatDate` rename.** `formatCustomerDate` avoids a clash with `src/lib/format.ts:formatDate` (used elsewhere in the app). All call sites inside `CustomerDetailView` must be updated. 3. **`memo` effectiveness.** `CustomerCard` and `CustomerDetailView` will only skip re-renders if every prop is referentially stable. `customer`, `vehicles`, `repairOrders`, `quotes` come from `useState` and are only replaced on fetch — stable between renders. Callbacks (`onView`, `onEdit`, etc.) are wrapped in `useCallback`. The memo is real; if a future change introduces an inline callback, memo breaks silently. 4. **No new tests are added.** Roadmap 2.4 for Appointments also didn't add tests for the extracted components; this matches the precedent. If the user wants snapshot tests for `CustomerCard`/`CustomerDetailView`, that's a separate task. **Tradeoffs:** - More files to navigate, but each is smaller and single-purpose. - The barrel import makes tree-shaking easier if any component is lazy-loaded later. **Open questions:** None. The pattern is proven on Appointments.tsx (Phase 2.4, 2026-07-06 memo). Customer behavior is unchanged. --- ## Verification summary ``` npm run build → PASS (0 errors, 0 warnings new) npm run lint → PASS (baseline warnings only) npm run test → PASS (86+ tests; no regressions) ``` **Resolved roadmap priority:** 2.4 (continues the mega-component split, Customers.tsx this time). Next up per the pattern: RepairOrders.tsx (already partially memoized — `FragmentRow`/`CompletedRow` exist) or QuoteGenerator.tsx.