Files
probo/contrib/claude/app-arborescence.md
Émile Ré ff966b462e Promote useMutation to the @probo/relay package
Extract the awaitable useMutation into @probo/relay as a
createUseMutation factory that delegates feedback to an injected
MutationNotifier, keeping the package free of UI and i18n
dependencies. compliance-portal binds it to its Base UI toast +
i18next + formatError stack and imports it by explicit path
(#/lib/relay/useMutation), dropping the lone intra-app barrel; a
compliance-portal-scoped no-restricted-imports rule forbids
react-relay's useMutation.

Bring packages/relay and packages/routes into the shared ESLint
scope and fix the violations that surfaced, and deprecate the
legacy withQueryRef / loaderFromQueryLoader helpers. Document the
shared-hook pattern and the "index.ts for package entrypoints only"
rule in the relay, hooks, and app-arborescence guides.

Signed-off-by: Émile Ré <emile@probo.com>
2026-06-26 18:52:05 +02:00

21 KiB

App arborescence (folder and file layout)

Conventions for organising pages, routes, and supporting files in Probo frontend apps (apps/compliance-portal, apps/console). The guiding principle is one arborescence: the route hierarchy is expressed once, through the pages/ folder tree, and everything related to a route lives next to it.

These rules are the source of truth. Where existing code disagrees (e.g. apps/console still keeps some route definitions in a separate src/routes/ folder), the code is non-compliant and should be migrated — it is not precedent.

Topic Guide
@probo/ui, Tailwind, tailwind-variants, folders, skeletons, compound modules contrib/claude/ui.md
React component shape, props, file/export conventions, naming/suffixes contrib/claude/react-components.md
Relay queries, fragments, loaders, queryRef contrib/claude/relay.md
Error boundaries at any level, error/fallback props contrib/claude/error-handling.md
i18next translations and _locales folders contrib/claude/i18n.md

Single arborescence principle

The pages/ folder is the route tree. Every route segment maps to a folder under pages/, and route definitions live inside that folder as routes.ts. No other root-level folder should replicate the same hierarchy.

Do / don't: route file placement

// Bad — separate routes/ folder duplicates pages/ structure
src/
  routes/
    thirdPartyRoutes.ts          # route definitions for third parties
    assetRoutes.ts           # route definitions for assets
  pages/
    organizations/
      third-parties/
        ThirdPartiesPage.tsx
      assets/
        AssetsPage.tsx
// Good — routes.ts colocated with the pages it references
src/
  pages/
    organizations/
      third-parties/
        routes.ts            # route definitions for third parties
        ThirdPartiesPage.tsx
      assets/
        routes.ts            # route definitions for assets
        AssetsPage.tsx

Existing examples that already follow this pattern: pages/organizations/compliance-page/routes.ts and pages/iam/organizations/people/routes.ts. The parent route file (routes.tsx at the app root) imports and spreads them:

import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes";

// inside the route tree array
...compliancePageRoutes,

Special files

Each page folder may contain a subset of these files. Names use PascalCase matching the feature.

File Role
routes.ts Route definitions for this folder. Exports an array spread into the parent route tree. Uses lazy() from @probo/react-lazy to point at loaders / pages.
MyLayout.tsx A layout route component that renders shared chrome (Breadcrumb, PageHeader, Tabs, …) and an <Outlet />. Named with the Layout suffix — never Page — to make its role obvious at a glance.
MyLayoutLoader.tsx Loader for a layout that needs data (same pattern as MyPageLoader).
MyPageLoader.tsx Bundle entry point imported by lazy() in the route. Default export. loads data via Relay, renders a skeleton while loading, then mounts the page with queryRef. Only needed when the page reads data.
MyPage.tsx The actual page component. Receives queryRef from the loader (when data is loaded), or is the default export directly imported by lazy() when no data is needed.
MyPageSkeleton.tsx Suspense fallback rendered while the page is still receiving data. Also used as the route-level Fallback. Only needed when the page reads data.
MyPageError.tsx Error UI rendered by a boundary for this page (see error-handling.md). An *Error file may exist at any level, not just the route root.
_components/ Sub-components scoped to this page (see below).
_lib/ Non-component helpers scoped to this subtree: hooks, utilities, constants, types (see below).
_locales/ i18next translation catalogs for this route segment, one file per locale (en-US.json). Colocated with routes.ts (see below).

Layout vs Page naming

A component is a layout when it renders <Outlet /> and exists to provide shared UI (breadcrumbs, tabs, page header) around child routes. It is a page when it renders final content with no <Outlet />.

Use the correct suffix so the role is clear from the file name alone:

// Bad — a layout route named as a "Page"
ThirdPartyDetailPage.tsx          # renders <Outlet />, wraps child routes
CookieBannerConfigPage.tsx    # renders tabs + <Outlet />

// Good — layout routes use the "Layout" suffix
ThirdPartyDetailLayout.tsx
CookieBannerConfigLayout.tsx

When do you need a loader, query, or Relay provider?

The loader / query / provider scaffolding exists to support Relay — it isn't boilerplate to copy into every page. Match the files to what the page actually does:

Page has… Files needed
No Relay data and no mutation MyPage.tsx only. lazy() imports it directly. No loader, no skeleton, no provider, no query.
A mutation only (no query) MyPage.tsx wrapped in the appropriate Relay provider (e.g. IAMRelayProvider, CoreRelayProvider). No loader, no skeleton, no query.
A query (with or without a mutation) Full pattern: MyPageLoader.tsx (provider + useQueryLoader + skeleton) → MyPage.tsx (usePreloadedQuery) + MyPageSkeleton.tsx.

Layouts follow the same rule. A layout that only renders a PageHeader and an <Outlet /> does not need a query, loader, skeleton, or provider — just a plain component imported by lazy() in routes.ts.

Example: mutation only, no query

See pages/iam/organizations/NewOrganizationPage.tsx for a reference. The page is the default export, wraps its inner component in IAMRelayProvider so the mutation has a Relay environment, and has no loader / skeleton / query.

// pages/iam/organizations/NewOrganizationPage.tsx
function NewOrganizationPageInner() {
  const [createOrganization, isCreating]
    = useMutation<NewOrganizationPageMutation>(createOrganizationMutation);
  // …
}

export default function NewOrganizationPage() {
  return (
    <IAMRelayProvider>
      <NewOrganizationPageInner />
    </IAMRelayProvider>
  );
}

Example: no data at all

// pages/organizations/cookie-banners/CookieBannerLayout.tsx
export default function CookieBannerLayout() {
  const { __ } = useTranslate();
  usePageTitle(__("Cookie Banners"));

  return (
    <div className="space-y-6">
      <PageHeader title={__("Cookie Banners")} />
      <Outlet />
    </div>
  );
}

routes.ts points lazy() at CookieBannerLayout directly — no CookieBannerLayoutLoader, no cookieBannerLayoutQuery, no skeleton.

routes.ts

Contains route objects for the current folder's feature, exported as a named array and spread into the parent. Keep imports minimal — only lazy, skeleton components, and typing.

// pages/organizations/third-parties/routes.ts
import { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";

import { ThirdPartiesPageSkeleton } from "./ThirdPartiesPageSkeleton";

export const thirdPartyRoutes = [
  {
    path: "third-parties",
    Fallback: ThirdPartiesPageSkeleton,
    Component: lazy(() => import("./ThirdPartiesPageLoader")),
  },
  {
    path: "third-parties/:thirdPartyId",
    Fallback: ThirdPartiesPageSkeleton,
    Component: lazy(() => import("./ThirdPartyDetailLayoutLoader")),
    children: [
      {
        path: "overview",
        Component: lazy(() => import("./overview/ThirdPartyOverviewPage")),
      },
    ],
  },
] satisfies AppRoute[];

MyPageLoader.tsx

The loader is the lazy bundle entry point. It sets up providers, triggers the Relay query, shows a skeleton until the query resolves, then renders the page.

// pages/organizations/third-parties/ThirdPartiesPageLoader.tsx
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";

import type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";

import ThirdPartiesPage, { thirdPartiesPageQuery } from "./ThirdPartiesPage";
import { ThirdPartiesPageSkeleton } from "./ThirdPartiesPageSkeleton";

function ThirdPartiesPageQueryLoader() {
  const organizationId = useOrganizationId();
  const [queryRef, loadQuery] = useQueryLoader<ThirdPartiesPageQuery>(thirdPartiesPageQuery);

  useEffect(() => {
    loadQuery({ organizationId });
  }, [loadQuery, organizationId]);

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

  return <ThirdPartiesPage queryRef={queryRef} />
}

export default function ThirdPartiesPageLoader() {
  return (
    <CoreRelayProvider>
      <ThirdPartiesPageQueryLoader />
    </CoreRelayProvider>
  );
}

MyPage.tsx

Receives the queryRef from the loader and renders the UI. Default export so lazy() can import it.

// pages/organizations/third-parties/ThirdPartiesPage.tsx
export default function ThirdPartiesPage({ queryRef }: ThirdPartiesPageProps) {
  const data = usePreloadedQuery(thirdPartiesPageQuery, queryRef);
  return (/* … */);
}

MyPageSkeleton.tsx

A lightweight loading placeholder. Keep it free of data-fetching logic so it loads instantly.

// pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
export function ThirdPartiesPageSkeleton() {
  return (/* pulse / skeleton UI */);
}

MyPageError.tsx

Rendered by the route error boundary when the page throws.

// pages/organizations/third-parties/ThirdPartiesPageError.tsx
export function ThirdPartiesPageError() {
  const error = useRouteError();
  return (/* error UI */);
}

File naming

Component files (.tsx that export a React component) use PascalCase: ThirdPartiesPage.tsx, ThirdPartyContactListItem.tsx, ThirdPartiesPageSkeleton.tsx.

All other helper files (utilities, hooks, constants, configuration) use camelCase: routes.ts, useThirdPartyFilters.ts, formatCurrency.ts, constants.ts.

Do / don't: file naming

// Bad — helper file in PascalCase
pages/organizations/third-parties/FormatThirdPartyStatus.ts
pages/organizations/third-parties/UseThirdPartyFilters.ts
pages/organizations/third-parties/Routes.ts

// Good — helpers are camelCase, components are PascalCase
pages/organizations/third-parties/formatThirdPartyStatus.ts
pages/organizations/third-parties/useThirdPartyFilters.ts
pages/organizations/third-parties/routes.ts
pages/organizations/third-parties/ThirdPartiesPage.tsx
pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx

Page sections

When a detail page has visually distinct sections (e.g. a properties card and a paginated list), extract each section into its own component in _components/. Each section owns a colocated Relay fragment (or pagination fragment) so that field additions never modify the parent page's query.

The page query spreads the section fragments and passes the fragment key to each section component:

// TrackerPatternDetailPage.tsx (page — spreads section fragments)
export const trackerPatternDetailPageQuery = graphql`
  query TrackerPatternDetailPageQuery($trackerPatternId: ID!) {
    node(id: $trackerPatternId) {
      ... on TrackerPattern {
        id
        displayName
        ...TrackerPatternPropertiesSection_trackerPattern
        ...TrackerPatternDetectedTrackersSection_trackerPattern
      }
    }
  }
`;

// In JSX:
<TrackerPatternPropertiesSection trackerPatternKey={pattern} />
<TrackerPatternDetectedTrackersSection trackerPatternKey={pattern} />
// _components/TrackerPatternPropertiesSection.tsx — owns its fragment
const fragment = graphql`
  fragment TrackerPatternPropertiesSection_trackerPattern on TrackerPattern {
    pattern
    matchType
    trackerType
    // ...
  }
`;

export function TrackerPatternPropertiesSection({ trackerPatternKey }: Props) {
  const pattern = useFragment(fragment, trackerPatternKey);
  return <Card padded>{/* PropertyRows */}</Card>;
}

Section components follow the same naming and fragment conventions as connection item components (see below), but represent a logical section of a page rather than a single list item.

_components folder

Sub-components that are used only by a single page live in a _components/ folder next to that page. The underscore prefix visually distinguishes them from route-segment folders.

Situation Where the component lives
Used by one page only pages/organizations/third-parties/_components/
Used by multiple pages in the same feature Nearest common ancestor's _components/ (e.g. pages/organizations/_components/)
Reusable UI primitive @probo/ui package

Do / don't: component placement

// Bad — shared component buried in a single page's _components
pages/organizations/third-parties/_components/StatusBadge.tsx    # also used by risks page
pages/organizations/risks/SomeRiskPage.tsx                 # imports ../../third-parties/_components/StatusBadge

// Good — shared component hoisted to common ancestor
pages/organizations/_components/StatusBadge.tsx
// Bad — page-specific helper placed in a global folder
src/components/ThirdPartyContactListItem.tsx     # only used by the third-parties feature

// Good — scoped to the page that uses it
pages/organizations/third-parties/_components/ThirdPartyContactListItem.tsx

_lib folder

Non-component code scoped to a subtree — hooks, utilities, constants, types — lives in a _lib/ folder next to the pages that use it. The same hoisting rule as _components/ applies: shared helpers move to the nearest common ancestor's _lib/; truly global helpers live in src/lib/. Files in _lib/ use camelCase (useThirdPartyFilters.ts, formatCurrency.ts, constants.ts). See contrib/claude/hooks.md for custom-hook conventions.

// Good — feature-scoped helpers under _lib
pages/organizations/third-parties/
  _lib/
    useThirdPartyFilters.ts
    formatThirdPartyStatus.ts
  _components/
    ThirdPartyListItem.tsx

No barrel files

App code imports every module by its explicit path (#/lib/relay/useMutation, #/lib/http/endpoint). Do not add index.ts re-export barrels inside an app's src/ to shorten import paths.

index.ts barrels are reserved for package public entrypoints (packages/*), where they define a real published API surface (@probo/ui, @probo/relay, …). An app has no published surface, so a barrel hides nothing — it only adds an indirection hop and invites Vite/tree-shaking and circular-import problems.

// Bad — barrel inside the app just to shorten a path
src/lib/relay/
  index.ts            # export { useMutation } from "./useMutation"
  useMutation.ts
// import { useMutation } from "#/lib/relay"

// Good — import the module directly; no index.ts
src/lib/relay/
  useMutation.ts
// import { useMutation } from "#/lib/relay/useMutation"

_locales folder

Translations are i18next catalogs in a _locales/ folder, one file per locale, named by locale tag: en-US.json, fr-FR.json. See contrib/claude/i18n.md for key conventions and setup.

_locales/ is colocated with a routes.ts. The rule of thumb:

  • A _locales/ folder belongs at a folder that names a resource and owns a routes.ts (e.g. organizations/routes.ts + organizations/_locales/, organizations/measures/routes.ts + organizations/measures/_locales/).
  • There must be no more _locales/ folders than there are routes.ts files. If a folder has no routes.ts, it does not get its own _locales/ — its strings live in the nearest ancestor that does.
// Good — _locales sits beside routes.ts at each resource boundary
pages/organizations/
  routes.ts
  _locales/
    en-US.json
    fr-FR.json
  measures/
    routes.ts
    _locales/
      en-US.json
      fr-FR.json
    MeasuresPage.tsx

// Bad — a _locales folder with no sibling routes.ts (strings belong to the parent resource)
pages/organizations/measures/_components/
  _locales/
    en-US.json

Child-route folder naming

Folders that contain child-route pages are named after the resource or concept the page represents, not after the UX component that currently renders them. UX patterns change (tabs become pages, drawers become routes, etc.); the resource name stays stable.

Do / don't: child-route folders

// Bad — folder named after a UI element
configuration/
  tabs/                            # "tabs" is a UI component, not a resource
    ThirdPartyOverviewTab.tsx
    ThirdPartyComplianceTab.tsx

// Good — folders named after the resource each child route represents
configuration/
  overview/
    ThirdPartyOverviewPage.tsx
  compliance/
    ThirdPartyCompliancePage.tsx

This also means child-route components use the *Page suffix (not *Tab), because they are pages in their own right — the fact that a tab bar navigates between them is an implementation detail of the parent layout.

Full example tree

Target layout for a third-parties feature under pages/organizations/:

pages/organizations/third-parties/
  routes.ts                        # route definitions for third parties
  _locales/                        # i18next catalogs (one _locales per routes.ts)
    en-US.json
    fr-FR.json
  ThirdPartiesPageLoader.tsx            # lazy entry — providers + Suspense + query loader
  ThirdPartiesPage.tsx                  # page component (usePreloadedQuery)
  ThirdPartiesPageSkeleton.tsx          # loading fallback
  ThirdPartiesPageError.tsx             # error UI for this page's boundary
  ThirdPartyDetailLayoutLoader.tsx     # lazy entry for detail layout
  ThirdPartyDetailLayout.tsx           # layout — breadcrumbs, tabs, <Outlet />
  ThirdPartyDetailLayoutSkeleton.tsx   # detail loading fallback
  NewThirdPartyPage.tsx                # mutation-only page — default export, wraps itself in the Relay provider
  _lib/                            # hooks / utils / constants scoped to third party pages
    useThirdPartyFilters.ts
  _components/                     # sub-components used only by third party pages
    ThirdPartyContactListItem.tsx
    ThirdPartyRiskSummarySection.tsx
  overview/                        # child route: /third-parties/:thirdPartyId/overview
    ThirdPartyOverviewPage.tsx
  compliance/                      # child route: /third-parties/:thirdPartyId/compliance
    ThirdPartyCompliancePage.tsx
  contacts/                        # child route: /third-parties/:thirdPartyId/contacts
    ThirdPartyContactsPage.tsx