Fix cookie mutations to update Relay store

The create, delete, and move cookie mutations were not updating
the Relay store connections, so the UI only reflected changes
after a page reload. Add @connection and @appendEdge/@deleteEdge
directives, and a store updater for the move mutation.

Also document mutation store update rules in contrib/claude/relay.md.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-21 13:46:11 +04:00
parent 29c0d55e75
commit 8e420da5bc
2 changed files with 140 additions and 7 deletions

View File

@@ -33,7 +33,7 @@ import {
} from "@probo/ui"; } from "@probo/ui";
import { useState } from "react"; import { useState } from "react";
import { useFragment, useMutation } from "react-relay"; import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime"; import { ConnectionHandler, graphql } from "relay-runtime";
import type { CategorySectionCreateCookieMutation } from "#/__generated__/core/CategorySectionCreateCookieMutation.graphql"; import type { CategorySectionCreateCookieMutation } from "#/__generated__/core/CategorySectionCreateCookieMutation.graphql";
import type { CategorySectionDeleteCookieMutation } from "#/__generated__/core/CategorySectionDeleteCookieMutation.graphql"; import type { CategorySectionDeleteCookieMutation } from "#/__generated__/core/CategorySectionDeleteCookieMutation.graphql";
@@ -58,7 +58,10 @@ export const categorySectionFragment = graphql`
name name
description description
kind kind
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) @required(action: THROW) { cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@connection(key: "CategorySection_cookies")
@required(action: THROW) {
__id
edges { edges {
node { node {
id id
@@ -109,14 +112,16 @@ const updateCategoryMutation = graphql`
const createCookieMutation = graphql` const createCookieMutation = graphql`
mutation CategorySectionCreateCookieMutation( mutation CategorySectionCreateCookieMutation(
$input: CreateCookieInput! $input: CreateCookieInput!
$connections: [ID!]!
) { ) {
createCookie(input: $input) { createCookie(input: $input) {
cookieEdge { cookieEdge @appendEdge(connections: $connections) {
node { node {
id id
name name
duration duration
description description
...EditCookieRowFragment
} }
} }
cookieBanner { cookieBanner {
@@ -158,9 +163,10 @@ const updateCookieMutation = graphql`
const deleteCookieMutation = graphql` const deleteCookieMutation = graphql`
mutation CategorySectionDeleteCookieMutation( mutation CategorySectionDeleteCookieMutation(
$input: DeleteCookieInput! $input: DeleteCookieInput!
$connections: [ID!]!
) { ) {
deleteCookie(input: $input) { deleteCookie(input: $input) {
deletedCookieId deletedCookieId @deleteEdge(connections: $connections)
cookieBanner { cookieBanner {
id id
latestVersion { latestVersion {
@@ -225,6 +231,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
const [editingCookieId, setEditingCookieId] = useState<string | null>(null); const [editingCookieId, setEditingCookieId] = useState<string | null>(null);
const [isAddingCookie, setIsAddingCookie] = useState(false); const [isAddingCookie, setIsAddingCookie] = useState(false);
const cookiesConnectionId = category.cookies.__id;
const cookies = category.cookies.edges.map(e => e.node); const cookies = category.cookies.edges.map(e => e.node);
const isMutating = isUpdating || isCreating || isUpdatingCookie; const isMutating = isUpdating || isCreating || isUpdatingCookie;
@@ -276,6 +283,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
duration: cookie.duration, duration: cookie.duration,
description: cookie.description, description: cookie.description,
}, },
connections: [cookiesConnectionId],
}, },
onCompleted(_response, errors) { onCompleted(_response, errors) {
if (errors?.length) { if (errors?.length) {
@@ -360,6 +368,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
deleteCookie({ deleteCookie({
variables: { variables: {
input: { cookieId }, input: { cookieId },
connections: [cookiesConnectionId],
}, },
onCompleted(_response, errors) { onCompleted(_response, errors) {
if (errors?.length) { if (errors?.length) {
@@ -400,6 +409,38 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
targetCookieCategoryId: targetCategoryId, targetCookieCategoryId: targetCategoryId,
}, },
}, },
updater(store) {
const sourceCategory = store.get(category.id);
if (sourceCategory) {
const sourceConn = ConnectionHandler.getConnection(
sourceCategory,
"CategorySection_cookies",
);
if (sourceConn) {
ConnectionHandler.deleteNode(sourceConn, cookieId);
}
}
const targetCategory = store.get(targetCategoryId);
if (targetCategory) {
const targetConn = ConnectionHandler.getConnection(
targetCategory,
"CategorySection_cookies",
);
if (targetConn) {
const cookieRecord = store.get(cookieId);
if (cookieRecord) {
const newEdge = ConnectionHandler.createEdge(
store,
targetConn,
cookieRecord,
"CookieEdge",
);
ConnectionHandler.insertEdgeAfter(targetConn, newEdge);
}
}
}
},
onCompleted(_response, errors) { onCompleted(_response, errors) {
if (errors?.length) { if (errors?.length) {
toast({ toast({

View File

@@ -262,6 +262,8 @@ The `@connection(key: "...", filters: [...])` directive on the fragment tells Re
## Mutations ## Mutations
Every mutation **must** update the Relay store so the UI reflects changes immediately — never rely on a page reload. Use `@appendEdge`/`@prependEdge` for creates, `@deleteEdge` for deletes, node `id` returns for in-place updates, and `updater` functions for complex multi-connection operations.
### `useMutation` ### `useMutation`
Direct Relay hook for simple cases. Direct Relay hook for simple cases.
@@ -335,10 +337,51 @@ const onSubmit = (formData: FormData) => {
### Store update directives ### Store update directives
Relay directives handle connection updates automatically — no manual store manipulation needed: Relay directives handle connection updates automatically — no manual store manipulation needed.
#### Connection setup
Any connection that a mutation will add to or remove from **must** have a `@connection` directive and expose `__id`:
```tsx ```tsx
// Add new edge to the beginning of a connection const fragment = graphql`
fragment CategorySectionFragment on CookieCategory {
id
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@connection(key: "CategorySection_cookies")
@required(action: THROW) {
__id
edges {
node {
id
...EditCookieRowFragment
}
}
}
}
`;
const category = useFragment(fragment, categoryKey);
const connectionId = category.cookies.__id;
```
When the mutation is triggered from a component that doesn't have access to the connection's `__id` (e.g. a sibling's child rather than a direct descendant), derive the connection ID with `ConnectionHandler.getConnectionID`:
```tsx
import { ConnectionHandler } from "relay-runtime";
const connectionId = ConnectionHandler.getConnectionID(
parentNodeId, // the store ID of the node that owns the connection
"CategorySection_cookies", // the @connection key
);
```
This is useful for dialogs, drawers, or other components rendered outside the subtree that reads the connection.
#### Directive examples
```tsx
// Add new edge to a connection
const createMutation = graphql` const createMutation = graphql`
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) { mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
createVendor(input: $input) { createVendor(input: $input) {
@@ -361,7 +404,7 @@ const deleteMutation = graphql`
} }
`; `;
// Update in-place via fragment spread (no directive needed) // Update in-place (Relay matches by id — no directive needed)
const updateMutation = graphql` const updateMutation = graphql`
mutation UpdateContactMutation($input: UpdateVendorContactInput!) { mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
updateVendorContact(input: $input) { updateVendorContact(input: $input) {
@@ -375,6 +418,55 @@ const updateMutation = graphql`
The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment. The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment.
#### Fragment spreads in create mutations
When a create mutation returns a new edge, its `node` selection **must** include all fragment spreads used by the list that renders it. This ensures the store has every field the UI needs to render the new item without a refetch:
```tsx
// Bad — missing fragment spread, child components will have missing data
cookieEdge @appendEdge(connections: $connections) {
node { id name duration description }
}
// Good — spreads the same fragment the list uses to render each item
cookieEdge @appendEdge(connections: $connections) {
node { id name duration description ...EditCookieRowFragment }
}
```
#### `updater` for complex store changes
When a single mutation affects multiple connections (e.g. moving an item between two lists) and the server payload doesn't return both an edge and a deletedId, use an `updater` function with `ConnectionHandler`:
```tsx
import { ConnectionHandler } from "relay-runtime";
moveCookie({
variables: { input: { cookieId, targetCookieCategoryId: targetId } },
updater(store) {
const source = store.get(sourceCategoryId);
if (source) {
const sourceConn = ConnectionHandler.getConnection(source, "CategorySection_cookies");
if (sourceConn) ConnectionHandler.deleteNode(sourceConn, cookieId);
}
const target = store.get(targetId);
if (target) {
const targetConn = ConnectionHandler.getConnection(target, "CategorySection_cookies");
if (targetConn) {
const node = store.get(cookieId);
if (node) {
const edge = ConnectionHandler.createEdge(store, targetConn, node, "CookieEdge");
ConnectionHandler.insertEdgeAfter(targetConn, edge);
}
}
}
},
});
```
Prefer declarative directives (`@appendEdge`, `@deleteEdge`) whenever possible; only fall back to `updater` when the operation cannot be expressed with directives alone.
### `useConfirm` for destructive actions ### `useConfirm` for destructive actions
Destructive mutations (delete) are wrapped with a confirmation dialog: Destructive mutations (delete) are wrapped with a confirmation dialog: