diff --git a/packages/relay-runtime/store/RelayReader.js b/packages/relay-runtime/store/RelayReader.js index 8776ac939cd62..e4efc89c53c8a 100644 --- a/packages/relay-runtime/store/RelayReader.js +++ b/packages/relay-runtime/store/RelayReader.js @@ -441,7 +441,10 @@ class RelayReader { value = this._asResult(_value); break; case 'NULL': - if (this._fieldErrors != null && this._fieldErrors.length > 0) { + if ( + this._fieldErrors != null && + this._fieldErrors.some(e => !e.handled) + ) { value = null; } break; @@ -479,12 +482,16 @@ class RelayReader { * responsibility to ensure that errors are marked as handled. */ _asResult(value: T): Result { - if (this._fieldErrors == null || this._fieldErrors.length === 0) { + // Errors already handled by an inner @catch must not influence this + // @catch's outcome — they have been consumed by their own boundary and + // are only flowing through `_fieldErrors` for logging. + const unhandled = this._fieldErrors?.filter(e => !e.handled); + if (unhandled == null || unhandled.length === 0) { return {ok: true, value}; } // TODO: Should we be hiding log level events here? - const errors = this._fieldErrors + const errors = unhandled .map(error => { switch (error.kind) { case 'relay_field_payload.error': diff --git a/packages/relay-runtime/store/__tests__/RelayReader-CatchFields-test.js b/packages/relay-runtime/store/__tests__/RelayReader-CatchFields-test.js index e7b214d69c754..04905325e7472 100644 --- a/packages/relay-runtime/store/__tests__/RelayReader-CatchFields-test.js +++ b/packages/relay-runtime/store/__tests__/RelayReader-CatchFields-test.js @@ -904,4 +904,114 @@ describe('RelayReader @catch', () => { }, ]); }); + + it('nested @catch(to: RESULT): server error on inner field should be handled by the inner @catch, not bubble to the outer @catch', () => { + const source = RelayRecordSource.create({ + 'client:root': { + __id: 'client:root', + __typename: '__Root', + me: {__ref: '1'}, + }, + '1': { + __id: '1', + id: '1', + __typename: 'User', + lastName: null, + __errors: { + lastName: [ + { + message: 'There was an error!', + path: ['me', 'lastName'], + }, + ], + }, + }, + }); + + const FooQuery = graphql` + query RelayReaderCatchFieldsTestNestedResultQuery { + me @catch(to: RESULT) { + lastName @catch(to: RESULT) + } + } + `; + const operation = createOperationDescriptor(FooQuery, {id: '1'}); + const {data, fieldErrors} = read(source, operation.fragment, null); + expect(data).toEqual({ + me: { + ok: true, + value: { + lastName: { + ok: false, + errors: [ + { + path: ['me', 'lastName'], + }, + ], + }, + }, + }, + }); + + expect(fieldErrors).toEqual([ + { + error: {message: 'There was an error!', path: ['me', 'lastName']}, + fieldPath: 'me.lastName', + handled: true, + kind: 'relay_field_payload.error', + owner: 'RelayReaderCatchFieldsTestNestedResultQuery', + shouldThrow: false, + }, + ]); + }); + + it('nested @catch(to: NULL): server error on inner field should be nulled by the inner @catch, leaving the outer field intact', () => { + const source = RelayRecordSource.create({ + 'client:root': { + __id: 'client:root', + __typename: '__Root', + me: {__ref: '1'}, + }, + '1': { + __id: '1', + id: '1', + __typename: 'User', + lastName: null, + __errors: { + lastName: [ + { + message: 'There was an error!', + path: ['me', 'lastName'], + }, + ], + }, + }, + }); + + const FooQuery = graphql` + query RelayReaderCatchFieldsTestNestedNullQuery { + me @catch(to: NULL) { + lastName @catch(to: NULL) + } + } + `; + const operation = createOperationDescriptor(FooQuery, {id: '1'}); + const {data, fieldErrors} = read(source, operation.fragment, null); + expect(data).toEqual({ + me: { + lastName: null, + }, + }); + + expect(fieldErrors).toEqual([ + { + error: {message: 'There was an error!', path: ['me', 'lastName']}, + fieldPath: 'me.lastName', + handled: true, + kind: 'relay_field_payload.error', + owner: 'RelayReaderCatchFieldsTestNestedNullQuery', + shouldThrow: false, + }, + ]); + }); }); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayReaderCatchFieldsTestNestedNullQuery.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayReaderCatchFieldsTestNestedNullQuery.graphql.js new file mode 100644 index 0000000000000..56b49012f8ee8 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayReaderCatchFieldsTestNestedNullQuery.graphql.js @@ -0,0 +1,116 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @oncall relay + * + * @generated SignedSource<> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest, Query } from 'relay-runtime'; +export type RelayReaderCatchFieldsTestNestedNullQuery$variables = {||}; +export type RelayReaderCatchFieldsTestNestedNullQuery$data = {| + +me: ?{| + +lastName: ?string, + |}, +|}; +export type RelayReaderCatchFieldsTestNestedNullQuery = {| + response: RelayReaderCatchFieldsTestNestedNullQuery$data, + variables: RelayReaderCatchFieldsTestNestedNullQuery$variables, +|}; +*/ + +var node/*: ConcreteRequest*/ = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "lastName", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayReaderCatchFieldsTestNestedNullQuery", + "selections": [ + { + "kind": "CatchField", + "field": { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "me", + "plural": false, + "selections": [ + { + "kind": "CatchField", + "field": (v0/*:: as any*/), + "to": "NULL" + } + ], + "storageKey": null + }, + "to": "NULL" + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "RelayReaderCatchFieldsTestNestedNullQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "me", + "plural": false, + "selections": [ + (v0/*:: as any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "2c315504ac774ed5e88367d3e2345856", + "id": null, + "metadata": {}, + "name": "RelayReaderCatchFieldsTestNestedNullQuery", + "operationKind": "query", + "text": "query RelayReaderCatchFieldsTestNestedNullQuery {\n me {\n lastName\n id\n }\n}\n" + } +}; +})(); + +if (__DEV__) { + (node/*:: as any*/).hash = "66a2c59d970a93665fcbbb90c9d8c17c"; +} + +module.exports = ((node/*:: as any*/)/*:: as Query< + RelayReaderCatchFieldsTestNestedNullQuery$variables, + RelayReaderCatchFieldsTestNestedNullQuery$data, +>*/); diff --git a/packages/relay-runtime/store/__tests__/__generated__/RelayReaderCatchFieldsTestNestedResultQuery.graphql.js b/packages/relay-runtime/store/__tests__/__generated__/RelayReaderCatchFieldsTestNestedResultQuery.graphql.js new file mode 100644 index 0000000000000..64313b9d298cc --- /dev/null +++ b/packages/relay-runtime/store/__tests__/__generated__/RelayReaderCatchFieldsTestNestedResultQuery.graphql.js @@ -0,0 +1,117 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @oncall relay + * + * @generated SignedSource<> + * @flow + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest, Query } from 'relay-runtime'; +import type { Result } from "relay-runtime"; +export type RelayReaderCatchFieldsTestNestedResultQuery$variables = {||}; +export type RelayReaderCatchFieldsTestNestedResultQuery$data = {| + +me: Result, + |}, unknown>, +|}; +export type RelayReaderCatchFieldsTestNestedResultQuery = {| + response: RelayReaderCatchFieldsTestNestedResultQuery$data, + variables: RelayReaderCatchFieldsTestNestedResultQuery$variables, +|}; +*/ + +var node/*: ConcreteRequest*/ = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "lastName", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayReaderCatchFieldsTestNestedResultQuery", + "selections": [ + { + "kind": "CatchField", + "field": { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "me", + "plural": false, + "selections": [ + { + "kind": "CatchField", + "field": (v0/*:: as any*/), + "to": "RESULT" + } + ], + "storageKey": null + }, + "to": "RESULT" + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "RelayReaderCatchFieldsTestNestedResultQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "me", + "plural": false, + "selections": [ + (v0/*:: as any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "f9e292ecc5dd08b5d8be80b0da603841", + "id": null, + "metadata": {}, + "name": "RelayReaderCatchFieldsTestNestedResultQuery", + "operationKind": "query", + "text": "query RelayReaderCatchFieldsTestNestedResultQuery {\n me {\n lastName\n id\n }\n}\n" + } +}; +})(); + +if (__DEV__) { + (node/*:: as any*/).hash = "91b7b93adcadec991cfe6f6bb03fcc1d"; +} + +module.exports = ((node/*:: as any*/)/*:: as Query< + RelayReaderCatchFieldsTestNestedResultQuery$variables, + RelayReaderCatchFieldsTestNestedResultQuery$data, +>*/);