Files
civitai__civitai/docs/trpc-router-dev-recompile-and-v11-notes.md
T
Briant Diehl 52c7899599 feat(trpc): migrate to tRPC v11 + React Query v5
Bump @trpc/* to ^11.17.0 and @tanstack/react-query(+devtools) to ^5.101.0.

Root config (src/utils/trpc.ts, src/server/trpc.ts):
- CreateTRPCNext takes 2 generics; type CreateTRPCProxyClient -> CreateTRPCClient
- transformer moves onto the link; createTRPCNext also needs it as a top-level option
- createCallerFactory off `t`; middleware rawInput -> async getRawInput()

React Query v5:
- mutation .isLoading -> .isPending (380 sites); isPreviousData -> isPlaceholderData
- client cacheTime -> gcTime (server cacheTime vars left untouched)
- keepPreviousData -> placeholderData via shared withPlaceholderData() helper + RQ fn
- query onSuccess/onError/onSettled removed -> useEffect (or awaited refetch)
- refetchInterval callback now receives the Query (query.state.data)
- useIsMutating(key) -> useIsMutating({ mutationKey }); getNextPageParam :0 -> :undefined
- DefaultErrorShape -> TRPCDefaultErrorShape; ReactQueryDevtools position -> buttonPosition

typecheck clean (0 errors). Routers kept eager; router.lazy() is a later phase.
See docs/trpc-v11-migration-plan.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:47:52 -06:00

7.9 KiB

tRPC route dev-recompile bottleneck — findings & tRPC v11 upgrade notes

Context for whoever picks up the tRPC v10 → v11 upgrade. This summarizes a session that investigated slow next dev recompiles and prototyped a fix. Self-contained — no prior context needed.

TL;DR

  • Symptom: editing any file under src/server/services/* triggers a recompile of the entire /api/trpc/[trpc] route — ~6,255 modules, ~21s cold / ~8.8s warm route rebuild — because pages/api/trpc/[trpc].tsappRouter (src/server/routers/index.ts) statically imports all ~93 routers → all their services into one compilation unit.
  • Root cause is architectural, not zod/schema init. (85 schema files / ~1,110 zod constructors were measured as a non-factor — zod construction is cheap.)
  • The clean fix is tRPC v11 lazy routers (router.lazy(() => import('./x.router'))), one line per router in index.ts. This defers each router + its service subgraph into its own chunk, so editing a service only rebuilds that chunk, not the whole route.
  • We proved the mechanism works by manually lazy-loading one service in v10 (see below), then reverted it — manual per-service await import() is too invasive for full coverage and the real win is the v11 router-level version.

What was verified (v10 manual prototype)

We converted every importer of generation.service.ts (router + controllers + services it's reached through) from static imports to lazy await import('...generation.service') at call sites. Result, confirmed in dev logs:

Edited file Compiling /api/trpc/[trpc] line? Meaning
generation.service.ts (converted → lazy) absent detached from route's eager graph
image.service.ts (unconverted → eager) present (✓ in 8.8s) still wired into the route

Diagnostic signature (use this to verify any detachment): edit the service on a warm server. If no ○ Compiling /api/trpc/[trpc] line appears, it's detached.

Caveats — read these before the v11 upgrade

1. ⚠️ Lazy-loading reorders module evaluation → can EXPOSE latent circular deps

This is the biggest risk for the v11 upgrade. Switching routers to lazy() changes which module first triggers a given dependency cluster's evaluation. A cluster with a pre-existing circular import that only worked due to a lucky eval order will start throwing at runtime:

TypeError: Cannot read properties of undefined (reading 'optional' | 'union' | ...)

…at module-top-level schema / DataGraph construction.

We hit exactly this: detaching generation.service surfaced a latent cycle in the data-graph: src/shared/data-graph/generation/*-graph.ts → common.ts → config/index → config/workflows.ts → *-graph.ts (config/workflows.ts imported version-id constants from the graph files, while the graphs import helpers back from it — a bidirectional edge).

Because v11 lazy routers shift the eval order of every router, expect MORE of these to surface across the whole codebase, not just the data-graph. Plan for a cycle-fixing pass.

Mitigations / fix patterns:

  • Enable the circular-dependency plugin first. next.config.mjs already contains a commented-out circular-dependency-plugin block; run with CIRCULAR_DEPENDENCY_PLUGIN=true to map cycles at build time before they bite at runtime. Do this up front.
  • Break bidirectional edges by extracting shared leaf values (constants, schemas, enums) into zero-import leaf modules. Example fix from this session: moved klingVersionIds / nanoBananaVersionIds / viduVersionIds out of the *-graph.ts files into a new leaf src/shared/data-graph/generation/version-ids.ts; both the graphs and config/workflows.ts import the leaf → edge becomes one-directional. (Graph files re-export the consts so existing handler imports keep working.)
  • Import leaf schemas from their source, not via a big module's re-export. kling-graph imported imageValueSchema through common.ts's re-export (common is in the cycle); fixed by importing directly from the leaf media-schemas.ts.

2. The re-seal floor — lazy routers won't make dev recompiles instant

Next.js webpack dev re-seals the entire server compilation on any server edit (you'll see a bare Compiled in Xs (N modules) with no route name). There's an irreducible floor (~4.4s warm in this codebase) regardless of how few modules changed. Lazy-loading removes the extra route rebuild on top of the floor (the 8.8s), not the floor itself. Set expectations: v11 lazy routers reduce per-edit cost meaningfully but don't eliminate it. If the floor itself becomes the bottleneck, that's the point to revisit Turbopack (different incremental engine, no re-seal) — see §4.

3. The (N modules) count in dev logs is NOT a per-edit delta

It's the total module count of the current compilation, and it grows as you visit more pages in a session. Two recompiles showing 6255 vs 11888 modules are not comparable if you've loaded different pages in between. To compare, hold session state constant and use (a) presence/absence of the Compiling /api/trpc/[trpc] line and (b) wall-clock time.

4. Turbopack was explored and parked (alternative path, not pursued)

next dev --turbo on this repo boots but hits a series of incompatibilities. If revisited, the known blockers are:

  • SharedWorker imported via server-absolute path (new URL('/src/workers/...', import.meta.url)) — Turbopack needs a relative path ('../../workers/...'). Works in webpack too.
  • CSS Modules :global nested inside native CSS nesting in *.module.css — Turbopack's Lightning CSS parser rejects it (css-loader tolerates it). Fix: don't nest :global {}; reference global keyframes by plain name (they resolve if defined outside the module).
  • @civitai/client ERR_UNSUPPORTED_DIR_IMPORT — needs transpilePackages: ['@civitai/client']. But that also makes the cold compile slower (Turbopack then transpiles the large generated client).
  • Net: cold compile was not faster; warm was. Parked in favor of the v11 path.

5. Hot-path first-request latency

A lazily-loaded module pays a one-time chunk compile on the first request to its endpoint after a server (re)start. Harmless in dev (one-time blip). If lazy routers are ever weighed for prod cold-start, image.service (the feed hot path, ~7.9K lines) is the one to watch.

tRPC v11 upgrade specifics

  • Current versions (all need bumping together): @trpc/server, @trpc/client, @trpc/next, @trpc/react-query — all ^10.45.0.
  • The dev-speed payoff is router.lazy() — wire it in src/server/routers/index.ts, ideally heaviest routers first (image, model, post, generation, orchestrator).
  • Follow the official v10→v11 migration guide for breaking changes (transformer config moved to the link, etc.) — the superjson transformer setup in src/server/trpc.ts and the client links will need updating.
  • Recommended sequence: (1) enable circular-dependency plugin & fix all reported cycles; (2) do the v11 API migration with routers still eager and confirm green typecheck + app boot; (3) only then convert routers to lazy() incrementally, watching for the runtime undefined signature from §1 as each is converted.

Quick reference — files touched in the (reverted) prototype

Server importers of generation.service that were converted: generation.router.ts, model.controller.ts, model-version.controller.ts, recommenders.controller.ts, model.service.ts, post.service.ts, orchestrator/common.ts, orchestration-new.service.ts, orchestrator/queue-limits.ts, models.search-index.ts. Type-only client imports and pages/api/** importers were left static (separate bundles; don't affect the tRPC route). Watch for relative-path importers (../services/...) — a ~/server/... grep misses them.