diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index dd3926bc7..d79e44ec5 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -15,7 +15,7 @@ React 19 + Vite + TypeScript + Relay + TailwindCSS. Port 5173. Defined in `src/routes.tsx` with feature-specific route files (e.g. `src/routes/assetRoutes.ts`). - Lazy-loaded via `lazy()` from `@probo/react-lazy` -- Data loading: `loaderFromQueryLoader()` + `loadQuery()` (Relay) +- Data loading: dedicated `*PageLoader` components with `useQueryLoader` (Relay) - Type: all routes `satisfies AppRoute[]` - Fallback: `PageSkeleton` or `Fallback` components during loading - Error boundaries per route group diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md index 2b6cac7f0..6b14eb196 100644 --- a/contrib/claude/relay.md +++ b/contrib/claude/relay.md @@ -26,34 +26,163 @@ Custom scalar mappings: `Datetime → string`, `GID → string`, `CursorKey → ## Colocated queries -Queries are defined inline in the file that uses them. Route-level queries are preloaded in the router loader before the component renders: +Queries are defined inline in the file that uses them. Route-level queries are preloaded in a dedicated `*PageLoader` component before the page renders. + +### Route definition + +Routes only declare `path`, `Fallback`, and `Component` pointing to a lazy-loaded loader component — no Relay logic in the route itself: ```tsx -// In route definition -{ - path: "vendors", - loader: loaderFromQueryLoader(({ organizationId }) => - loadQuery(coreEnvironment, vendorsQuery, { - organizationId, - snapshotId: null, - }), - ), - Component: withQueryRef( - lazy(() => import("#/pages/organizations/vendors/VendorsPage")), - ), -} +// In route file (e.g. findingRoutes.ts) +import { lazy } from "@probo/react-lazy"; +import type { AppRoute } from "@probo/routes"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; -// In the component -export default function VendorsPage(props: Props) { - const data = usePreloadedQuery(vendorsQuery, props.queryRef); +export const findingRoutes = [ + { + path: "findings", + Fallback: PageSkeleton, + Component: lazy( + () => import("#/pages/organizations/findings/FindingsPageLoader"), + ), + }, +] satisfies AppRoute[]; +``` + +### Loader component + +The loader component owns the Relay query lifecycle — it calls `useQueryLoader` + `useEffect` to preload, renders a skeleton while waiting, then wraps the real page in `Suspense`: + +```tsx +// FindingsPageLoader.tsx +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { FindingsPageListQuery } from "#/__generated__/core/FindingsPageListQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import FindingsPage, { findingsPageQuery } from "./FindingsPage"; + +export default function FindingsPageLoader() { + const organizationId = useOrganizationId(); + const { snapshotId } = useParams<{ snapshotId?: string }>(); + const [queryRef, loadQuery] + = useQueryLoader(findingsPageQuery); + + useEffect(() => { + loadQuery({ + organizationId, + snapshotId: snapshotId ?? null, + }); + }, [loadQuery, organizationId, snapshotId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} +``` + +### Page component + +The page receives `queryRef` as a prop and reads data with `usePreloadedQuery`: + +```tsx +// FindingsPage.tsx +export const findingsPageQuery = graphql` + query FindingsPageListQuery($organizationId: ID!, $snapshotId: ID) { + node(id: $organizationId) { + ... on Organization { + ...FindingsPageFragment @arguments(snapshotId: $snapshotId) + } + } + } +`; + +interface FindingsPageProps { + queryRef: PreloadedQuery; +}; + +export default function FindingsPage({ queryRef }: FindingsPageProps) { + const data = usePreloadedQuery(findingsPageQuery, queryRef); // ... } ``` -- `loaderFromQueryLoader` — converts a query loader into a React Router loader, returns `{ queryRef, dispose }` -- `withQueryRef` — extracts `queryRef` from loader data and handles cleanup on unmount +### `loaderFromQueryLoader` / `withQueryRef` (deprecated) -For queries that need to run after render (e.g. select dropdowns), use `useLazyLoadQuery` with `fetchPolicy: "network-only"`. +**Do not use.** Use a `*PageLoader` component with `useQueryLoader` as shown above instead. + +## Interaction-triggered queries + +When a user interaction (hover, click, open dialog) needs data beyond what the initial page query loaded, use a secondary query with `useQueryLoader` + `usePreloadedQuery`. This starts fetching in the event handler — before the target component renders — so the network request and component rendering overlap instead of running sequentially. + +The parent component owns the query lifecycle with `useQueryLoader`, triggers the fetch in the event handler, and passes the query ref down: + +```tsx +import { Suspense } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { PosterHovercardQuery as HovercardQueryType } from "#/__generated__/core/PosterHovercardQuery.graphql"; + +import PosterHovercard, { posterHovercardQuery } from "./PosterHovercard"; + +function PosterByline({ poster }: Props) { + const data = useFragment(posterBylineFragment, poster); + const [hovercardQueryRef, loadHovercardQuery] = + useQueryLoader(posterHovercardQuery); + + function onBeginHover() { + loadHovercardQuery({ posterId: data.id }); + } + + return ( + + {hovercardQueryRef && ( + }> + + + )} + + ); +} +``` + +The child component reads data with `usePreloadedQuery`: + +```tsx +import { graphql, usePreloadedQuery } from "react-relay"; +import type { PreloadedQuery } from "react-relay"; +import type { PosterHovercardQuery } from "#/__generated__/core/PosterHovercardQuery.graphql"; + +export const posterHovercardQuery = graphql` + query PosterHovercardQuery($posterId: ID!) { + node(id: $posterId) { + ... on Poster { + ...PosterHovercardBodyFragment + } + } + } +`; + +interface PosterHovercardProps { + queryRef: PreloadedQuery; +} + +export default function PosterHovercard({ queryRef }: PosterHovercardProps) { + const data = usePreloadedQuery(posterHovercardQuery, queryRef); + // ... +} +``` + +**Do not use `useLazyLoadQuery`** — it defers the fetch until the component renders, adding unnecessary latency. Always prefer `useQueryLoader` + `usePreloadedQuery` so the network request starts in the event handler. ## Fragments @@ -61,7 +190,7 @@ Fragments colocate data requirements with the component that reads them: ```tsx const contactFragment = graphql` - fragment VendorContactsTabFragment_contact on VendorContact { + fragment ContactRow_contactFragment on VendorContact { id fullName email @@ -74,7 +203,7 @@ const contactFragment = graphql` } `; -function ContactRow(props: { contactKey: VendorContactsTabFragment_contact$key }) { +function ContactRow(props: { contactKey: ContactRow_contactFragment$key }) { const contact = useFragment(contactFragment, props.contactKey); // ... } @@ -252,20 +381,17 @@ return () => { ## File organization -GraphQL operations are colocated with the components that use them: +GraphQL operations are colocated with the components that use them. See [`contrib/claude/app-arborescence.md`](app-arborescence.md) for the full folder layout. ``` pages/organizations/vendors/ VendorsPage.tsx # query + pagination fragment + _components/ + CreateContactDialog.tsx # create mutation + EditContactDialog.tsx # update mutation tabs/ VendorContactsTab.tsx # refetchable fragment + item fragment VendorComplianceTab.tsx - dialogs/ - CreateContactDialog.tsx # create mutation - EditContactDialog.tsx # update mutation - -hooks/graph/ - VendorGraph.ts # shared queries, mutations, hooks ``` -Shared queries and mutation hooks (used by multiple components) live in `hooks/graph/*.ts`. Component-specific operations are defined inline in the component file. +Component-specific operations (queries, fragments, mutations) are defined inline in the component file that uses them. Shared sub-components live in `_components/` next to the page (scoped to the nearest common ancestor).