Relay 实战:useMutationAction_EXPERIMENTAL 如何捕获顶级字段错误(data: null)
【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay
本篇以 Relay 仓库中的端到端测试 fixture(useMutationAction-top-level-field-error.md)为核心,讲解useMutationAction_EXPERIMENTAL在服务端返回{data: null, errors: [...]}(例如非空(non-null)顶级 mutation 字段执行失败)时的完整行为链路:Relay 为何将其视为致命错误、错误如何从底层网络一路路由到onError与 Promise 的reject,以及如何在 React 组件中用startTransition+try/catch兜住它。读完你可以直接在自己的应用中复刻这套「模拟 mutation 失败并断言错误 UI」的测试模式。
场景定义:什么是「顶级字段错误(data: null)」
在 GraphQL 中,一个 mutation 的响应负载(payload)可以是:
{data: {...}}:成功,字段级错误(如列表中某个元素失败)通常以data内字段为null外加顶层errors的形式表达;{data: null, errors: [...]}:整个顶层 mutation 无法产出数据。最典型的原因是顶层字段是非空(non-null)类型,而它的解析器抛错——非空约束的失败会向上冒泡,最终把整个data打成null;{errors: [...]}且无data:网络/传输层失败。
本 fixture 针对第二种情况:data: null且附带errors。文档标题中的 "Top-Level Field Error" 指的就是这种「非空顶级字段失败导致整棵数据树为空」的错误形态。
Relay 如何判定data: null为致命错误
data: null与「字段级错误」有本质区别。字段级错误时data对象仍然存在,Relay 可以正常完成归一化(normalization)与 UI 更新,errors仅作为附带信息传给onCompleted的第二个参数。而data: null意味着没有任何数据可以写入 store,Relay 无法完成一次成功的 mutation,因此直接将其视为致命(fatal)错误。
这一判断发生在 OperationExecutor.js 中:当响应对象的data == null时(排除仅有extensions的 payload),执行器会构造一个RelayNetwork错误并抛出:
No data returned for operation `<OperationName>`, got error(s): <errors 中每个 message 以换行拼接> See the error `source` property for more information.注意该错误对象上还挂了一个source属性,携带{errors, operation, variables}三份信息(见 OperationExecutor.js),供日志与上报工具深挖根因。
在测试快照 useMutationAction-top-level-field-error.snap.md 中,你可以在最终渲染的 HTML 里直接看到这条错误消息落到 UI 上:
Caught error: No data returned for operation `AppDoSomethingMutation`, got error(s): Non-nullable field failed See the error `source` property for more information.错误如何到达commitAction:从onError到 Promisereject
useMutationAction_EXPERIMENTAL的实现在 useMutationAction_EXPERIMENTAL.js 中,它本质上是useMutation的「action 化」变体:返回一个异步函数commitAction(variables),其内部通过commitMutation提交变更,并把两个回调桥接为 Promise 语义:
onCompleted(response)→resolve(response);onError(error)→reject(error)。
const commitAction = useCallback( (variables) => { return new Promise((resolve, reject) => { commitMutation(environment, { mutation, variables, onCompleted: (response) => resolve(response), onError: (error) => reject(error), }); }); }, [environment, mutation], );完整配置见 commitMutation.js:除了mutation、variables、onCompleted、onError,还支持optimisticResponse、optimisticUpdater、updater、cacheConfig、configs(声明式 mutation 配置)、uploadables等。其中commitMutation在订阅的error事件上直接透传onError,因此OperationExecutor抛出的data: null致命错误会沿executeMutation→ 订阅error→onError→reject的链路直达commitAction的调用方。
补充:
commitMutation对errors的收集行为见 commitMutation.js——单条或多条(批量)响应中的payload.errors会被累积起来,在complete时作为onCompleted的第二个参数传入。这正是「字段级错误走onCompleted、data: null致命错误走onError」两条路径的分水岭。
端到端 fixture 全解:从配置到断言
本 fixture 是relay-e2e-test包中「Markdown 驱动」的端到端测试:Markdown 里带title的代码块会被抽成真实文件,steps块会被解析成交互步骤,测试最终与同名的.snap.md快照比对。下面按原文档顺序逐一拆解。
1. Relay 配置(relay.config.json)
{ "src": "./", "schema": "./schema.graphql", "language": "typescript" }src:源码目录,编译器从这里收集 GraphQL 标签;schema:schema 文件路径(本 fixture 中由 Grats 从 TS 源码生成,见下文);language:生成产物的语言,本 fixture 使用typescript,以便useLazyLoadQuery<AppTestQuery>这样的泛型获得完整类型检查。
2. Schema 定义(server.ts,基于 Grats)
/** @gqlQueryField */ export function greeting(): string { return "Ready"; } /** @gqlMutationField */ export function doSomething(args: { input: string }): string { return "ok"; }这是通过 Grats 的@gqlQueryField/@gqlMutationField注释,从 TypeScript 函数直接生成 GraphQL schema 的写法:greeting: String!成为 query 字段,doSomething(input: String!): String!成为 mutation 字段。测试脚手架在 runFixture.js 中依次执行grats生成schema.graphql、再执行relay-compiler生成__generated__/*.graphql产物,随后对整个 fixture 跑一次tsc --noEmit类型检查(诊断会进入快照,见.snap.md顶部的 Type Errors 段)。
3. 模拟data: null响应的自定义 Network(App.tsx)
import { Suspense, useState, useTransition } from "react"; import { RelayEnvironmentProvider, useLazyLoadQuery, useMutationAction_EXPERIMENTAL, } from "react-relay"; import { graphql, Environment, Network, Observable } from "relay-runtime"; import { gratsNetwork } from "../GratsNetwork"; import { AppTestQuery } from "./__generated__/AppTestQuery.graphql"; import { AppDoSomethingMutation } from "./__generated__/AppDoSomethingMutation.graphql"; const dataNullNetwork = Network.create((operation, variables) => { if (operation.operationKind === "mutation") { return Observable.create((sink) => { sink.next({ data: null, errors: [{ message: "Non-nullable field failed" }], }); sink.complete(); }); } return gratsNetwork.execute(operation, variables, {}, null); }); const testEnvironment = new Environment({ network: dataNullNetwork });关键点:
- 按操作类型分流:自定义网络只在
operationKind === "mutation"时伪造data: null响应;query(AppTestQuery)则回落给 GratsNetwork.ts 中的真实 graphql 执行器,保证greeting正常渲染出 "Ready"。 - 伪造方式:
Observable.create直接sink.next({data: null, errors: [{message: ...}]})后complete(),精确模拟「HTTP 200、GraphQL 语义级失败」的服务端响应,绕开真实网络。 - 这也是文档所述「The environment uses a custom network that returns
{data: null, errors: [...]}for mutations, simulating a non-nullable top-level field error」的实现。
4. 组件层:用startTransition+try/catch捕获
function Content() { const data = useLazyLoadQuery<AppTestQuery>( graphql` query AppTestQuery { greeting } `, {}, ); const commitAction = useMutationAction_EXPERIMENTAL<AppDoSomethingMutation>( graphql` mutation AppDoSomethingMutation($input: String!) { doSomething(input: $input) } `, ); const [errorMessage, setErrorMessage] = useState<string | null>(null); const [isPending, startTransition] = useTransition(); return ( <div> <div>{data.greeting}</div> <button disabled={isPending} onClick={() => { startTransition(async () => { try { await commitAction({ input: "test" }); } catch (err) { setErrorMessage((err as Error).message); } }); }} > Submit </button> {errorMessage != null && <div>Caught error: {errorMessage}</div>} </div> ); }useLazyLoadQuery先渲染greeting,页面初始显示 "Ready";commitAction以AppDoSomethingMutation(带$input: String!变量)为参数;- 点击 Submit 后,
startTransition包裹的异步函数执行await commitAction({input: "test"}); - 由于网络返回
data: null,Promise 被reject,进入catch,把错误消息写入errorMessage状态,UI 渲染出 "Caught error: ..."; isPending用于在 transition 进行中禁用按钮,避免重复提交。
注意:commitAction的返回类型是Promise<TData>,因此在成功路径下还能拿到TData响应,与乐观更新(useOptimistic)配合实现 action 模式的乐观 UI(详见同目录 useMutationAction-optimistic.md)。
5. 交互断言(Steps 块)
wait "Ready" click button "Submit" wait "Caught error:"这段 DSL 由 runInteractions.js 执行:wait对应findByText/findByRole(等待元素出现),click对应userEvent.click。它验证了完整时序——先确认 query 数据 "Ready" 渲染,点击 Submit,再确认错误消息 "Caught error:" 出现。解析规则(click "Name"、click role "Name"、type、wait等)都在该文件的USAGE注释与parseStep中有完整定义。
6. 快照验证(.snap.md)
useMutationAction-top-level-field-error.snap.md 记录了两次验证结果:
- Type Errors 段:
App.tsx(17,9): error TS2353: ... 'errors' does not exist in type 'GraphQLResponseWithExtensionsOnly | ...'——这证明 fixture 的sink.next伪造对象在严格类型下并不完全符合relay-runtime的响应类型定义(GraphQLSingularResponse的data类型是GraphQLResponseWithExtensionsOnly | readonly GraphQLSingularResponse[],没有errors字段直接共存于data: null分支)。这是一个刻意保留、供人审视的类型瑕疵,快照机制使它在 CI 中持续可见,而不是悄悄被修掉或掩盖。 - HTML 段:最终渲染的 DOM 中出现了
Caught error: No data returned for operation ... got error(s): Non-nullable field failed,证明整条错误链路端到端打通。
与其他错误场景的对比
同一个mutationsfixture 目录还覆盖了data: null之外的若干错误形态,可以对照理解本场景的定位:
| 场景 | 响应形态 | 行为路径 | 对应 fixture |
|---|---|---|---|
| 顶级字段错误(本文) | data: null+errors | 致命错误 →onError→ Promise reject | useMutationAction-top-level-field-error.md |
| 字段级错误 | data存在 +errors | 非致命,正常完成,错误进onCompleted第二参数 | useMutationAction-field-errors.md、useMutationAction-catch-field-error.md |
| 网络错误 | 传输层异常 /Observable发出error | 致命错误 →onError→ Promise reject | useMutationAction-network-error-catch.md、useMutationAction-network-error-boundary.md |
此外还有 useMutationAction-form-action.md(把commitAction直接作为表单action属性使用)与 useMutationAction-sequential.md(连续提交)等 fixture,共同构成useMutationAction_EXPERIMENTAL的行为矩阵。
如何在真实应用中复刻这条测试路径
本 fixture 是可运行的测试,不只是文档。测试由 fixtures-test.js 驱动:读取目录下所有非.snap.md的 Markdown → parseMarkdown.js 用正则抽取title代码块与steps块 → setupTempDir.js 在临时目录重建文件(并生成指向本仓库relay-runtime/react-relay的 tsconfigpaths,使类型检查针对当前 commit 的.d.ts)→ runFixture.js 依次跑 Grats、relay-compiler、tsc → React Testing Library 渲染并执行交互 → 与快照比对。在packages/relay-e2e-test下执行yarn test:e2e或jest --config jest.config.js即可运行全部 fixture。
应用到你的项目中时,只需保留两个核心模式:
- 伪造网络层:
Network.create中对 mutation 返回{data: null, errors: [...]},即可零成本复现「非空顶级字段失败」; - 组件层兜底:始终用
try/catch包裹await commitAction(...),或在更外层放置错误边界捕获未处理的 rejection,因为data: null永远不会走onCompleted的成功分支。
【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考