Files
probo/contrib/claude/relay.md
Émile Ré 74d7d3ff25 Upgrade Relay to v20.1.1 and unify compiler config
Consolidate the two per-app relay configs (console and trust)
into a single multi-project relay.config.json at the repo root
with three projects: core, iam, and trust. Bump all relay
packages from v19 to v20.1.1 and move relay-compiler to the
root devDependencies. Replace per-workspace relay scripts with
a single root-level npm run relay command and update the
GNUmakefile, CI workflows, and docs accordingly.

Signed-off-by: Émile Ré <emile@getprobo.com>
2026-04-14 16:04:11 +04:00

12 KiB

Relay (Frontend GraphQL Client)

The console app uses Relay as its GraphQL client. All GraphQL operations are defined inline with the graphql template tag from relay-runtime — there are no separate .graphql files on the frontend.

Environments

Two Relay environments connect to two separate GraphQL APIs:

Environment Endpoint Purpose
coreEnvironment /api/console/v1/graphql Main application data
iamEnvironment /api/connect/v1/graphql Authentication / identity

Configured in apps/console/src/environments.ts. Each has its own store with 1-minute query cache expiration.

Relay compiler

Config lives in relay.config.json at the repo root with three projects (core, iam, trust) mapped to different source directories and schemas. Generated files go into __generated__/ directories.

npm run relay          # clean + compile (from repo root)
npm run relay-compile  # compile only (from repo root)

Custom scalar mappings: Datetime → string, GID → string, CursorKey → string, Duration → string, BigInt → number, EmailAddr → string.

Colocated queries

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:

// 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";

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:

// 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<FindingsPageListQuery>(findingsPageQuery);

  useEffect(() => {
    loadQuery({
      organizationId,
      snapshotId: snapshotId ?? null,
    });
  }, [loadQuery, organizationId, snapshotId]);

  if (!queryRef) {
    return <PageSkeleton />;
  }

  return (
    <Suspense fallback={<PageSkeleton />}>
      <FindingsPage queryRef={queryRef} />
    </Suspense>
  );
}

Page component

The page receives queryRef as a prop and reads data with usePreloadedQuery:

// 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<FindingsPageListQuery>;
};

export default function FindingsPage({ queryRef }: FindingsPageProps) {
  const data = usePreloadedQuery(findingsPageQuery, queryRef);
  // ...
}

loaderFromQueryLoader / withQueryRef (deprecated)

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:

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<HovercardQueryType>(posterHovercardQuery);

  function onBeginHover() {
    loadHovercardQuery({ posterId: data.id });
  }

  return (
    <HoverTrigger onBeginHover={onBeginHover}>
      {hovercardQueryRef && (
        <Suspense fallback={<Spinner />}>
          <PosterHovercard queryRef={hovercardQueryRef} />
        </Suspense>
      )}
    </HoverTrigger>
  );
}

The child component reads data with usePreloadedQuery:

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<PosterHovercardQuery>;
}

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

Fragments colocate data requirements with the component that reads them:

const contactFragment = graphql`
  fragment ContactRow_contactFragment on VendorContact {
    id
    fullName
    email
    phone
    role
    createdAt
    updatedAt
    canUpdate: permission(action: "core:vendor-contact:update")
    canDelete: permission(action: "core:vendor-contact:delete")
  }
`;

function ContactRow(props: { contactKey: ContactRow_contactFragment$key }) {
  const contact = useFragment(contactFragment, props.contactKey);
  // ...
}

Refetchable fragments

For lists that support sorting and pagination, use @refetchable with @argumentDefinitions:

const vendorContactsFragment = graphql`
  fragment VendorContactsTabFragment on Vendor
  @refetchable(queryName: "VendorContactsListQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 50 }
    order: { type: "VendorContactOrder", defaultValue: null }
    after: { type: "CursorKey", defaultValue: null }
    before: { type: "CursorKey", defaultValue: null }
    last: { type: "Int", defaultValue: null }
  ) {
    contacts(
      first: $first
      after: $after
      last: $last
      before: $before
      orderBy: $order
    ) @connection(key: "VendorContactsTabFragment_contacts") {
      __id
      edges {
        node {
          ...VendorContactsTabFragment_contact
        }
      }
    }
  }
`;

const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor);
const connectionId = data.contacts.__id;

Pagination

Use usePaginationFragment for cursor-based Relay pagination:

const pagination = usePaginationFragment(paginatedVendorsFragment, data.node);
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
const connectionId = pagination.data.vendors.__id;

The @connection(key: "...", filters: [...]) directive on the fragment tells Relay how to manage the paginated list in the store. The filters array controls which variables affect the connection identity.

SortableTable is the standard component for rendering paginated, sortable lists — it receives pagination (with loadNext, hasNext, isLoadingNext) and a refetch callback for sorting.

Mutations

useMutation

Direct Relay hook for simple cases:

const [deleteVendor] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);

For mutations with user feedback, combine with useToast and use onCompleted/onError callbacks:

const { toast } = useToast();
const [createObligation, isCreating] = useMutation<CreateObligationMutation>(createObligationMutation);

const onSubmit = (formData: FormData) => {
  createObligation({
    variables: {
      input: { ...formData },
      connections: [connectionId],
    },
    onCompleted() {
      toast({
        title: __("Success"),
        description: __("Obligation created successfully"),
        variant: "success",
      });
    },
    onError(error) {
      toast({
        title: __("Error"),
        description: formatError(__("Failed to create obligation"), error as GraphQLError),
        variant: "error",
      });
    },
  });
};

useMutationWithToasts (deprecated)

Do not use. Use useMutation combined with useToast instead.

promisifyMutation (deprecated)

Do not use. Use useMutation with onCompleted/onError callbacks instead of wrapping in a promise.

Store update directives

Relay directives handle connection updates automatically — no manual store manipulation needed:

// Add new edge to the beginning of a connection
const createMutation = graphql`
  mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
    createVendor(input: $input) {
      vendorEdge @prependEdge(connections: $connections) {
        node {
          id
          name
        }
      }
    }
  }
`;

// Remove an edge from a connection
const deleteMutation = graphql`
  mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) {
    deleteVendor(input: $input) {
      deletedVendorId @deleteEdge(connections: $connections)
    }
  }
`;

// Update in-place via fragment spread (no directive needed)
const updateMutation = graphql`
  mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
    updateVendorContact(input: $input) {
      vendorContact {
        ...VendorContactsTabFragment_contact
      }
    }
  }
`;

The connections variable is obtained from the __id field on the connection in the parent query/fragment.

useConfirm for destructive actions

Destructive mutations (delete) are wrapped with a confirmation dialog:

const confirm = useConfirm();
const [deleteVendor] = useMutation<DeleteVendorMutation>(deleteVendorMutation);

return () => {
  confirm(
    () =>
      new Promise<void>((resolve) => {
        deleteVendor({
          variables: {
            input: { vendorId: vendor.id! },
            connections: [connectionId],
          },
          onCompleted() {
            resolve();
          },
          onError() {
            resolve();
          },
        });
      }),
    { message: "Confirm deletion..." },
  );
};

File organization

GraphQL operations are colocated with the components that use them. See contrib/claude/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

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).