Document Relay @required and export rules
Capture two conventions surfaced while building the top bar: use the Relay @required directive to make expected-present nullable fields non-null for consistent typing, and reserve default exports for the component that lazy() imports as a bundle entry while everything else uses named exports. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
45
.cursor/rules/react-named-exports-lazy-entry.mdc
Normal file
45
.cursor/rules/react-named-exports-lazy-entry.mdc
Normal file
@@ -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 → <MainLayout /> */ }
|
||||
|
||||
// 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`.
|
||||
48
.cursor/rules/relay-required-directive.mdc
Normal file
48
.cursor/rules/relay-required-directive.mdc
Normal file
@@ -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).
|
||||
Reference in New Issue
Block a user