# PRD — Tickets List Load-Time Reduction (bundle/hydration) **Status:** Draft for review **Owner:** TBD **Created:** 2026-06-15 **Plan slug:** 2026-06-15-tickets-list-bundle-reduction --- ## 1. Problem statement & user value The MSP ticketing dashboard (`/msp/tickets`) is slow to become usable. A production performance trace (algapsa.com, warm cache) shows: - **LCP 3,048 ms**, of which **2,774 ms (91%) is render delay** — the browser receives HTML quickly (TTFB 274 ms) but can't paint until it finishes executing the route's JS. - **~9.3 MB of decoded JavaScript across 103 chunks** hydrating on the list route. - Backend is **not** the bottleneck: server actions return in ~24–27 ms. - The list route eagerly bundles heavy client code that is **not on screen at first paint**: - 7 dialogs imported only by the list (`BulkAssign/Tags/DueDate/Status/Priority`, `TicketImportDialog`, `TicketExportDialog`). - The full `ClientDetails` component (2,215 lines + all client tabs) for the side-drawer quick view, even though the drawer only ever shows the first ("details") tab. - Each list view also fires **48 redundant RSC prefetch requests** (two ``s per row × ~24 rows) that force the server to render ticket detail pages nobody asked for. **User value:** the tickets list paints and becomes interactive noticeably faster, and the server stops doing wasted detail-page renders on every list view. ## 2. Goals - **G1 (primary):** Reduce tickets-list initial JavaScript / hydration cost and improve LCP / render delay, measured by build output (list route first-load JS) and a repeat production-style performance trace. - **G2:** Remove the redundant per-row RSC prefetch traffic from the list. - **G3:** Replace the full-weight client drawer with a genuinely lightweight `ClientQuickView`, and reuse it at **every** client quick-view call site. - **G4:** Move the 7 list-only dialogs out of the list route bundle via route-level code splitting (static imports only). ## 3. Non-goals - **No `next/dynamic` / `React.lazy` / `import()` code-splitting.** Architectural decision: the team avoids dynamic imports to keep module boundaries clean. - **No changes to `QuickAddTicket` or its rich-text editor.** It is shared across 9 surfaces (including the global header quick-create), so list-only extraction yields little app-wide benefit. Tracked as a possible separate future plan. - **No change to `QuickAddCategory`** — shared by 4 components, not cleanly list-only. - App-shell server-action burst (~20 POSTs during hydration) and the 15 s job-metrics poll (`Header.tsx`) — real but shell-level; out of scope here. - Deep-linkable / refresh-safe dialog URLs are **not a goal** (acceptable side effect of intercepting routes, but we will not invest extra to guarantee it). ## 4. Hard constraints - **C1 — No visual change for end users.** Dialogs must still appear as modal overlays on top of the list (not full-page navigations). The client drawer must look identical. - **C2 — Dialogs must respect all current list filters** (and, for bulk actions, the current row selection), exactly as today. - **C3 — No dynamic imports** (see non-goals). - **C4 — Multi-tenant safety unchanged.** This is primarily a UI/bundling refactor; any server actions reused by new routes keep their existing `tenant` scoping. > **Routing decision (derived from C1 + C2 + C3):** Plain separate full pages are ruled > out (they change the UX from modal → full navigation, violating C1). Keeping the dialogs > inside the list component keeps them in the list bundle (fails G1/G4). With dynamic > imports off the table (C3), the only approach that satisfies all three is **Next.js > intercepting routes + a parallel `@modal` slot**: each dialog lives in its own route > segment (so it's a separate, statically-imported route bundle) but renders as a modal > overlay over the still-mounted list. This pattern does not exist in the codebase yet. ## 5. Affected users / flows - MSP internal users on the tickets list: open Quick View on a client, run bulk actions on selected tickets, import/export tickets. All flows must look and behave the same. - Any user hitting the other client quick-view surfaces (clients list, billing dashboard, ticket detail drawer, projects, interactions, user list) — they get the new lighter component with identical appearance. ## 6. Approach (workstreams) ### A. Row-link prefetch fix (smallest, immediate) - Add `prefetch={false}` to the two ``s in `packages/tickets/src/lib/ticket-columns.tsx` (ticket-number col ~line 219, title col ~line 273). Clicks are already intercepted (`e.preventDefault()` → `onTicketClick`), and middle-click / open-in-new-tab keep working via the retained `href`. ### B. Lightweight `ClientQuickView` + reuse everywhere - Rebuild `packages/clients/src/components/clients/ClientQuickView.tsx` so it imports only the "details" tab content (what `ClientDetails` renders when `quickView` → `tabContent[0]`), **not** the full `ClientDetails` module (which statically imports contacts list, billing config, interactions feed, notes panel, and 3 Hudu tabs). - Extract the shared details-tab content into a small component so both `ClientDetails` (full page) and `ClientQuickView` can use it without `ClientQuickView` dragging in the other tabs. - Wire **all** client quick-view call sites to `ClientQuickView`: - `packages/msp-composition/src/tickets/MspTicketsPageClient.tsx:18` - `packages/msp-composition/src/tickets/MspTicketDetailsContainerClient.tsx:71` - `packages/msp-composition/src/clients/MspClientDrawerProvider.tsx:36` - `packages/msp-composition/src/billing/MspBillingDashboardClient.tsx:18` - `packages/msp-composition/src/projects/MspClientIntegrationProvider.tsx:44` - `packages/clients/src/components/clients/Clients.tsx:1779` - `packages/clients/src/components/interactions/InteractionDetails.tsx:209` - `packages/projects/src/components/Projects.tsx` (renderClientDetails, ~1024) - `server/src/components/settings/general/UserList.tsx:206` - (verify contact-context `` usages: `Contacts.tsx:697`, `MspContactTickets.tsx:230` — classify as client quick view or leave) ### C. Route-extract the 7 list-only dialogs (intercepting + parallel routes) - Add `server/src/app/msp/tickets/layout.tsx` hosting `{children}` + a `@modal` parallel slot, and `server/src/app/msp/tickets/@modal/default.tsx` returning `null`. - Create route segments for each dialog under `msp/tickets/...` with an intercepting `(.)` variant in `@modal` that renders the dialog as an overlay, plus a plain segment for the hard-load fallback. - Triggers on the list become navigations (`router.push` / ``) to those routes instead of toggling local `isOpen` state; the dialog components move into the route segments so they leave the list route's module graph. - **Shared state:** introduce a tickets-segment client context provider (in `tickets/layout.tsx`) holding the current **filters** and **selected ticket ids**, so the intercepting modal routes (sibling subtrees that cannot read the list page's local state) can honor C2. The list writes to it; dialog routes read from it. Filters are already partly URL-synced (`TicketingDashboard.tsx:510,625`) and can be read from `searchParams` as a backstop. - Dialog order by effort/benefit: **Import → Export** (no selection dependency, easiest, likely heaviest) first; then the **5 bulk dialogs** (require the selection context). ## 7. Data model / API notes No schema changes. Existing server actions (bulk assign/tags/due-date/status/priority, import, export, client fetch) are reused unchanged and keep their `tenant` scoping. Export/bulk-select-all must continue to pass the current `ITicketListFilters` to the server action so results respect filters (C2). ## 8. Risks & mitigations - **R1 — Intercepting/parallel routes are a new pattern here.** Risk of focus/scroll/ back-button regressions and SSR quirks. *Mitigation:* prototype one dialog (Import) end-to-end first; add a `default.tsx`; manual + e2e checks for open/close/refresh/back. - **R2 — Selection-state lifting is the highest-effort item.** Selection lives deep in `TicketingDashboard` (`selectedTicketIds`, line 252). *Mitigation:* lift to a small tickets-scoped context; if a bulk dialog's effort:benefit is poor, it may stay in-list (re-evaluate during A/B; user scoped "all" but ROI matters). - **R3 — Filter fidelity (C2).** Modal routes must see the exact active filters. *Mitigation:* shared context is source of truth; e2e assert each dialog acts on filtered set. - **R4 — `ClientQuickView` is reused at ~10 sites.** Rebuilding it risks regressions across clients/billing/projects/tickets. *Mitigation:* keep visual output identical to current `quickView` details tab; verify each call site. - **R5 — "No visual change" (C1).** Modal-from-route must match current modal styling/ behavior. *Mitigation:* reuse the existing `Dialog` components inside the route segments. ## 9. Success metrics / Definition of Done - **DoD-1:** Production-style repeat trace of `/msp/tickets` shows reduced render delay and LCP vs. the 2,774 ms / 3,048 ms baseline (record exact numbers in SCRATCHPAD). - **DoD-2:** `next build` shows the tickets-list route **first-load JS reduced**; the 7 dialogs and full `ClientDetails` no longer appear in the list route chunk graph. - **DoD-3:** Loading the list fires **0** per-row RSC prefetch requests; clicking a ticket still opens it; middle-click/open-in-new-tab still work. - **DoD-4:** Every client quick-view surface renders the lightweight `ClientQuickView` and looks identical to today. - **DoD-5:** Each dialog opens as a modal overlay (no full-page nav), respects current filters/selection, and performs its action correctly. No visual change reported. - **DoD-6:** No `next/dynamic` / `React.lazy` introduced. ## 10. Open questions - OQ1: Confirm the intercepting-routes approach is acceptable as a new pattern (it's the only option satisfying C1+C2+C3). If not, we must relax C1 (allow full pages) or C3. - OQ2: Is there an existing client-side store pattern (zustand/jotai/context) preferred for the tickets selection/filter context, or should we add a plain React context? - OQ3: For bulk dialogs with poor effort:benefit, is leaving them in-list acceptable, or is full extraction of all 7 required? - OQ4: Should `Contacts.tsx:697` / `MspContactTickets.tsx:230` `` usages be migrated to `ClientQuickView` too, or are they intentionally full?