From c551b33bfb8003ffd63bec93cbcd62828d99ce7e Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 10 Jul 2026 17:38:12 +0200 Subject: [PATCH] refactor(core-trpc): break the core-api package cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core-trpc imported AppRouter (type-only) from core-api, which depends on the features, which depend on core-trpc — a cycle that killed every turbo graph walk (lint/build/CI) and an illegal core -> core-composition boundary edge. The tRPC context is now router-agnostic (AnyTRPCRouter); useTRPC takes the router type as a generic and each feature exports a type-only app-slice (e.g. BlogAppSlice) mirroring its mount key, so UI hooks keep full procedure typing without touching the composition layer. Co-Authored-By: Claude Fable 5 --- packages/blog/src/integrations/api/router.ts | 10 ++++++++ .../blog/src/ui/hooks/use-article-by-slug.ts | 3 ++- .../blog/src/ui/hooks/use-article-list.ts | 3 ++- packages/core-trpc/package.json | 1 - packages/core-trpc/src/client.ts | 18 ++++++++++++-- packages/core-trpc/src/index.ts | 1 - .../core-trpc/src/providers/next-provider.tsx | 6 +++-- .../src/providers/tanstack-provider.tsx | 6 +++-- .../src/integrations/api/router.ts | 10 ++++++++ .../src/ui/hooks/use-page-by-slug.ts | 3 ++- .../src/ui/hooks/use-site-settings.ts | 3 ++- .../navigation/src/integrations/api/router.ts | 24 ++++++++++++------- .../navigation/src/ui/hooks/use-header.ts | 3 ++- pnpm-lock.yaml | 3 --- 14 files changed, 70 insertions(+), 24 deletions(-) diff --git a/packages/blog/src/integrations/api/router.ts b/packages/blog/src/integrations/api/router.ts index 5c24c2f..af74435 100644 --- a/packages/blog/src/integrations/api/router.ts +++ b/packages/blog/src/integrations/api/router.ts @@ -43,3 +43,13 @@ export const blogRouter = router({ }); export type BlogRouter = typeof blogRouter; + +/** + * This feature's slice as it is mounted in the app router (the `blog` key + * in @repo/core-api). UI hooks pass it to `useTRPC()` so + * they stay fully typed without core-trpc (or this feature's client code) + * depending on the composition layer. Type-only: erased at compile time. + */ +export type BlogAppSlice = ReturnType< + typeof router<{ blog: typeof blogRouter }> +>; diff --git a/packages/blog/src/ui/hooks/use-article-by-slug.ts b/packages/blog/src/ui/hooks/use-article-by-slug.ts index 7c688d0..3e29494 100644 --- a/packages/blog/src/ui/hooks/use-article-by-slug.ts +++ b/packages/blog/src/ui/hooks/use-article-by-slug.ts @@ -2,10 +2,11 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useTRPC } from "@repo/core-trpc"; +import type { BlogAppSlice } from "../../integrations/api/router"; import type { Article } from "../../entities/models/article"; export function useArticleBySlug(slug: string) { - const trpc = useTRPC(); + const trpc = useTRPC(); return useSuspenseQuery(trpc.blog.articleBySlug.queryOptions({ slug })) as { data: Article | null; }; diff --git a/packages/blog/src/ui/hooks/use-article-list.ts b/packages/blog/src/ui/hooks/use-article-list.ts index 2898ae9..19f0b20 100644 --- a/packages/blog/src/ui/hooks/use-article-list.ts +++ b/packages/blog/src/ui/hooks/use-article-list.ts @@ -2,13 +2,14 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useTRPC } from "@repo/core-trpc"; +import type { BlogAppSlice } from "../../integrations/api/router"; import type { Article } from "../../entities/models/article"; export function useArticleList(options?: { status?: "draft" | "published"; limit?: number; }) { - const trpc = useTRPC(); + const trpc = useTRPC(); return useSuspenseQuery( trpc.blog.listArticles.queryOptions({ status: options?.status ?? "published", diff --git a/packages/core-trpc/package.json b/packages/core-trpc/package.json index a41588c..60f9ce2 100644 --- a/packages/core-trpc/package.json +++ b/packages/core-trpc/package.json @@ -15,7 +15,6 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { - "@repo/core-api": "workspace:*", "@tanstack/react-query": "^5.66.0", "@trpc/client": "^11.17.0", "@trpc/react-query": "^11.17.0", diff --git a/packages/core-trpc/src/client.ts b/packages/core-trpc/src/client.ts index 722b8c1..b0c68bf 100644 --- a/packages/core-trpc/src/client.ts +++ b/packages/core-trpc/src/client.ts @@ -1,6 +1,20 @@ "use client"; import { createTRPCContext } from "@trpc/tanstack-react-query"; -import type { AppRouter } from "@repo/core-api"; +import type { TRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import type { AnyTRPCRouter } from "@trpc/server"; -export const { TRPCProvider, useTRPC } = createTRPCContext(); +// core-trpc must not depend on @repo/core-api (core → core-composition is an +// illegal boundary edge and creates a package cycle through the features). +// The runtime context is router-agnostic; consumers inject their router type +// as a generic: features pass their app-slice type (e.g. `BlogAppSlice`), +// apps may pass the full AppRouter. +const context = createTRPCContext(); + +export const TRPCProvider = context.TRPCProvider; + +export function useTRPC< + TRouter extends AnyTRPCRouter = AnyTRPCRouter, +>(): TRPCOptionsProxy { + return context.useTRPC() as unknown as TRPCOptionsProxy; +} diff --git a/packages/core-trpc/src/index.ts b/packages/core-trpc/src/index.ts index 7b27b5d..94a24e8 100644 --- a/packages/core-trpc/src/index.ts +++ b/packages/core-trpc/src/index.ts @@ -1,3 +1,2 @@ export { useTRPC, TRPCProvider } from "./client"; export { getQueryClient } from "./query-client"; -export type { AppRouter } from "@repo/core-api"; diff --git a/packages/core-trpc/src/providers/next-provider.tsx b/packages/core-trpc/src/providers/next-provider.tsx index dc4b9d8..0b24838 100644 --- a/packages/core-trpc/src/providers/next-provider.tsx +++ b/packages/core-trpc/src/providers/next-provider.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { createTRPCClient, httpBatchLink } from "@trpc/client"; import superjson from "superjson"; -import type { AppRouter } from "@repo/core-api"; +import type { AnyTRPCRouter } from "@trpc/server"; import { TRPCProvider } from "../client"; import { getQueryClient } from "../query-client"; @@ -22,7 +22,9 @@ export function NextTrpcProvider({ }) { const [queryClient] = useState(() => getQueryClient()); const [trpcClient] = useState(() => - createTRPCClient({ + // Router-agnostic on purpose: procedure typing comes from useTRPC + // at the call sites, not from the wire-level client. + createTRPCClient({ links: [ httpBatchLink({ url: `${getBaseUrl()}${trpcUrl}`, diff --git a/packages/core-trpc/src/providers/tanstack-provider.tsx b/packages/core-trpc/src/providers/tanstack-provider.tsx index 0f48647..c5e46dc 100644 --- a/packages/core-trpc/src/providers/tanstack-provider.tsx +++ b/packages/core-trpc/src/providers/tanstack-provider.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { createTRPCClient, httpBatchLink } from "@trpc/client"; import superjson from "superjson"; -import type { AppRouter } from "@repo/core-api"; +import type { AnyTRPCRouter } from "@trpc/server"; import { TRPCProvider } from "../client"; import { getQueryClient } from "../query-client"; @@ -17,7 +17,9 @@ export function TanstackTrpcProvider({ }) { const [queryClient] = useState(() => getQueryClient()); const [trpcClient] = useState(() => - createTRPCClient({ + // Router-agnostic on purpose: procedure typing comes from useTRPC + // at the call sites, not from the wire-level client. + createTRPCClient({ links: [httpBatchLink({ url: trpcUrl, transformer: superjson })], }), ); diff --git a/packages/marketing-pages/src/integrations/api/router.ts b/packages/marketing-pages/src/integrations/api/router.ts index 024ec54..d7e1f64 100644 --- a/packages/marketing-pages/src/integrations/api/router.ts +++ b/packages/marketing-pages/src/integrations/api/router.ts @@ -32,3 +32,13 @@ export const marketingPagesRouter = router({ }); export type MarketingPagesRouter = typeof marketingPagesRouter; + +/** + * This feature's slice as it is mounted in the app router (the `marketingPages` key + * in @repo/core-api). UI hooks pass it to `useTRPC()` so + * they stay fully typed without core-trpc (or this feature's client code) + * depending on the composition layer. Type-only: erased at compile time. + */ +export type MarketingPagesAppSlice = ReturnType< + typeof router<{ marketingPages: typeof marketingPagesRouter }> +>; diff --git a/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts b/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts index 284dbc0..bc94ee4 100644 --- a/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts +++ b/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts @@ -2,10 +2,11 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useTRPC } from "@repo/core-trpc"; +import type { MarketingPagesAppSlice } from "../../integrations/api/router"; import type { Page } from "../../entities/models/page"; export function usePageBySlug(slug: string) { - const trpc = useTRPC(); + const trpc = useTRPC(); // A missing slug now rejects with NOT_FOUND (PageNotFoundError), so a // resolved query always carries a Page. return useSuspenseQuery( diff --git a/packages/marketing-pages/src/ui/hooks/use-site-settings.ts b/packages/marketing-pages/src/ui/hooks/use-site-settings.ts index c0ed45e..db4ddf7 100644 --- a/packages/marketing-pages/src/ui/hooks/use-site-settings.ts +++ b/packages/marketing-pages/src/ui/hooks/use-site-settings.ts @@ -2,10 +2,11 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useTRPC } from "@repo/core-trpc"; +import type { MarketingPagesAppSlice } from "../../integrations/api/router"; import type { SiteSettings } from "../../entities/models/site-settings"; export function useSiteSettings() { - const trpc = useTRPC(); + const trpc = useTRPC(); return useSuspenseQuery( trpc.marketingPages.siteSettings.queryOptions({}), ) as { data: SiteSettings }; diff --git a/packages/navigation/src/integrations/api/router.ts b/packages/navigation/src/integrations/api/router.ts index 16ccb76..dc2c467 100644 --- a/packages/navigation/src/integrations/api/router.ts +++ b/packages/navigation/src/integrations/api/router.ts @@ -9,14 +9,22 @@ import type { IGetHeaderController } from "../../interface-adapters/controllers/ import { navigationProcedure } from "./procedures"; export const navigationRouter = router({ - header: navigationProcedure - .input(getHeaderInputSchema) - .query(({ input }) => { - const ctrl = navigationContainer.get( - NAVIGATION_SYMBOLS.IGetHeaderController, - ); - return ctrl(input); - }), + header: navigationProcedure.input(getHeaderInputSchema).query(({ input }) => { + const ctrl = navigationContainer.get( + NAVIGATION_SYMBOLS.IGetHeaderController, + ); + return ctrl(input); + }), }); export type NavigationRouter = typeof navigationRouter; + +/** + * This feature's slice as it is mounted in the app router (the `navigation` key + * in @repo/core-api). UI hooks pass it to `useTRPC()` so + * they stay fully typed without core-trpc (or this feature's client code) + * depending on the composition layer. Type-only: erased at compile time. + */ +export type NavigationAppSlice = ReturnType< + typeof router<{ navigation: typeof navigationRouter }> +>; diff --git a/packages/navigation/src/ui/hooks/use-header.ts b/packages/navigation/src/ui/hooks/use-header.ts index a9c618c..3926557 100644 --- a/packages/navigation/src/ui/hooks/use-header.ts +++ b/packages/navigation/src/ui/hooks/use-header.ts @@ -2,10 +2,11 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useTRPC } from "@repo/core-trpc"; +import type { NavigationAppSlice } from "../../integrations/api/router"; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function useHeader() { - const trpc = useTRPC(); + const trpc = useTRPC(); return useSuspenseQuery(trpc.navigation.header.queryOptions({})) as { data: { items: { label: string; href: string; external: boolean }[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b4dfd4..c13b64a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -930,9 +930,6 @@ importers: packages/core-trpc: dependencies: - "@repo/core-api": - specifier: workspace:* - version: link:../core-api "@tanstack/react-query": specifier: ^5.66.0 version: 5.96.2(react@19.2.4)