diff --git a/.cursor/rules/react-named-exports-lazy-entry.mdc b/.cursor/rules/react-named-exports-lazy-entry.mdc new file mode 100644 index 000000000..db796e8a3 --- /dev/null +++ b/.cursor/rules/react-named-exports-lazy-entry.mdc @@ -0,0 +1,45 @@ +--- +description: Named exports for components; default export only for lazy() bundle entries +globs: "**/*.tsx" +alwaysApply: false +--- + +# Named exports; `export default` only for lazy() entries + +React components use **named exports**. `export default` is reserved as an +exception **only** for the component a router/`lazy()` imports as a bundle entry, +because `lazy(() => import("./X"))` resolves the module's default export. + +In the Loader + Page/Layout pattern this means exactly **one** default export per +chain — the Loader (the `lazy()` target). The Page/Layout it renders is imported +**directly by the Loader**, not through `lazy()`, so it must be a named export. + +```tsx +// GOOD — Loader is the lazy() entry → default export +// MainLayoutLoader.tsx +import { MainLayout, mainLayoutQuery } from "./MainLayout"; +export default function MainLayoutLoader() { /* useQueryLoader → */ } + +// GOOD — Layout/Page imported directly by the Loader → named export +// MainLayout.tsx +export const mainLayoutQuery = graphql`query MainLayoutQuery { ...TopBar_query }`; +export function MainLayout({ queryRef }: MainLayoutProps) { /* usePreloadedQuery */ } + +// routes.tsx — only the Loader is referenced by lazy() +Component: lazy(() => import("#/pages/MainLayoutLoader")), +``` + +```tsx +// BAD — Page/Layout defaulted even though the Loader (not lazy) imports it +// MainLayout.tsx +export default function MainLayout(/* ... */) {} +// MainLayoutLoader.tsx +import MainLayout, { mainLayoutQuery } from "./MainLayout"; +``` + +A page with no loader (no Relay data) that `lazy()` imports directly is itself +the bundle entry, so it keeps the `export default`. The rule is about *who +`lazy()` imports*, not about the "Page" vs "Layout" label. + +See `contrib/claude/react-components.md` (File and export) and +`contrib/claude/app-arborescence.md`. diff --git a/.cursor/rules/relay-required-directive.mdc b/.cursor/rules/relay-required-directive.mdc new file mode 100644 index 000000000..983b3bf22 --- /dev/null +++ b/.cursor/rules/relay-required-directive.mdc @@ -0,0 +1,48 @@ +--- +description: Use Relay @required to make expected-present nullable fields non-null +globs: "**/*.tsx" +alwaysApply: false +--- + +# Use `@required` for expected-present fields + +GraphQL schemas mark many fields nullable defensively, but at a given call site +you usually **expect** a value. When a field is nullable in the schema but the +component cannot meaningfully render without it, annotate it with `@required` so +the **generated type is non-null**. This keeps typing consistent: callers stop +threading `?.` / `?? ""` / `!` through code that always expects data, and a +genuinely-missing value becomes a real signal instead of a silently-empty UI. + +Choose the action by what should happen when the value is actually absent: + +- `@required(action: THROW)` — the value is an invariant for this view (a page's + root entity, the `currentTrustCenter` a portal is built around). A null throws + on read and propagates to the nearest error boundary. Field becomes non-null. +- `@required(action: LOG)` — a missing value should degrade gracefully: the null + bubbles to the nearest `@required` ancestor (or nulls the fragment data) and + Relay logs it. Use when the surrounding UI can render a fallback. +- `@required(action: NONE)` — bubble nullability without logging; rarely needed. + +```graphql +# GOOD — the view is built around this entity; THROW makes it non-null +currentTrustCenter @required(action: THROW) { + organization { + name # already String! — no @required needed + logo { downloadUrl } # legitimately optional — leave nullable + } +} +``` + +```tsx +// GOOD — non-null typing falls out of @required; no defensive chaining +const { organization } = data.currentTrustCenter; +const logoUrl = organization.logo?.downloadUrl ?? undefined; // logo stays optional +``` + +Do NOT use `@required` to silence nullability on fields that are *genuinely* +optional (avatar, logo, optional description) — those keep their nullable type +and get a real empty/fallback state. Do NOT use the `THROW` as control flow for +an expected-empty case (it is an error path, not a branch). Do NOT annotate +fields the schema already declares non-null (`String!`, `Organization!`). + +See `contrib/claude/relay.md` (Fragments → Required fields). diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md index 959604e7b..c11e0ada9 100644 --- a/contrib/claude/relay.md +++ b/contrib/claude/relay.md @@ -232,6 +232,35 @@ function ContactListItem(props: { contactKey: ContactListItem_contact$key }) { Select `permission(action:)` fields (aliased `canUpdate` / `canDelete`) in the fragment of the component that renders the action, and gate the UI on the resulting boolean. See [`contrib/claude/permissions.md`](permissions.md). +### Required fields (`@required`) + +GraphQL schemas mark many fields nullable defensively, but at a given call site you usually **expect** a value to be there. When a field is nullable in the schema but the component cannot meaningfully render without it, annotate it with `@required` so the **generated type is non-null**. This keeps typing honest and consistent: callers stop threading `?.` / `?? ""` / non-null `!` assertions through code that always expects data, and a genuinely-missing value surfaces as a real signal instead of silently rendering an empty UI. + +Pick the action by what should happen when the value is actually absent at runtime: + +- **`@required(action: THROW)`** — the value is an invariant for this view (e.g. a page's root entity, the `currentTrustCenter` a portal is built around). A null throws on read and propagates to the nearest error boundary (see [`error-handling.md`](error-handling.md)). The field becomes non-null in the type. +- **`@required(action: LOG)`** — a missing value should degrade gracefully rather than crash: the null **bubbles up** to the nearest `@required` ancestor (or makes the fragment/field data null), and Relay logs it. Use when the surrounding UI can render a sensible fallback. +- **`@required(action: NONE)`** — bubble nullability without logging; rarely needed. + +```graphql +# Good — the view is built around this entity; THROW makes it non-null +currentTrustCenter @required(action: THROW) { + organization { + name # already String! in the schema — no @required needed + logo { downloadUrl } # legitimately optional — leave nullable + } +} +``` + +```tsx +// Good — non-null typing falls out of @required; no defensive chaining +const { organization } = data.currentTrustCenter; +const name = organization.name; +const logoUrl = organization.logo?.downloadUrl ?? undefined; // logo stays optional +``` + +Do **not** reach for `@required` to silence nullability on fields that are *genuinely* optional (an avatar, a logo, a description that may be empty). Those keep their nullable type and get a real empty/fallback state. Likewise, never select a field, mark it `@required(action: THROW)`, and rely on the throw as control flow for an expected-empty case — that is an error path, not a branch. And there is no need to annotate fields the schema already declares non-null (`String!`, `Organization!`). + ### Refetchable fragments For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`: