Skip to content

Commit 94c8f0b

Browse files
committed
Avoid running resolver code if root fragment throws with @required(action: THROW)
1 parent 0319d87 commit 94c8f0b

File tree

7 files changed

+322
-13
lines changed

7 files changed

+322
-13
lines changed

packages/relay-runtime/store/RelayReader.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const {
6868
} = require('./RelayStoreUtils');
6969
const {NoopResolverCache} = require('./ResolverCache');
7070
const {
71-
RESOLVER_FRAGMENT_MISSING_DATA_SENTINEL,
71+
RESOLVER_FRAGMENT_ERRORED_SENTINEL,
7272
withResolverContext,
7373
} = require('./ResolverFragments');
7474
const {generateTypeID} = require('./TypeID');
@@ -653,6 +653,7 @@ class RelayReader {
653653
return {
654654
data: snapshot.data,
655655
isMissingData: snapshot.isMissingData,
656+
missingRequiredFields: snapshot.missingRequiredFields,
656657
};
657658
}
658659

@@ -665,6 +666,7 @@ class RelayReader {
665666
return {
666667
data: snapshot.data,
667668
isMissingData: snapshot.isMissingData,
669+
missingRequiredFields: snapshot.missingRequiredFields,
668670
};
669671
};
670672

@@ -721,7 +723,7 @@ class RelayReader {
721723
getDataForResolverFragment,
722724
);
723725

724-
this._propogateResolverMetadata(
726+
this._propagateResolverMetadata(
725727
field.path,
726728
cachedSnapshot,
727729
resolverError,
@@ -736,7 +738,7 @@ class RelayReader {
736738
// Reading a resolver field can uncover missing data, errors, suspense,
737739
// additional seen records and updated dataIDs. All of these facts must be
738740
// represented in the snapshot we return for this fragment.
739-
_propogateResolverMetadata(
741+
_propagateResolverMetadata(
740742
fieldPath: string,
741743
cachedSnapshot: ?Snapshot,
742744
resolverError: ?Error,
@@ -1455,7 +1457,7 @@ function getResolverValue(
14551457

14561458
resolverResult = resolverFunction.apply(null, resolverFunctionArgs);
14571459
} catch (e) {
1458-
if (e === RESOLVER_FRAGMENT_MISSING_DATA_SENTINEL) {
1460+
if (e === RESOLVER_FRAGMENT_ERRORED_SENTINEL) {
14591461
resolverResult = undefined;
14601462
} else {
14611463
resolverError = e;

packages/relay-runtime/store/ResolverCache.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
'use strict';
1313

14+
import type {MissingRequiredFields} from '..';
1415
import type {
1516
ReaderRelayLiveResolver,
1617
ReaderRelayResolver,
@@ -51,6 +52,7 @@ export type EvaluationResult<T> = {
5152
export type ResolverFragmentResult = {
5253
data: mixed,
5354
isMissingData: boolean,
55+
missingRequiredFields: ?MissingRequiredFields,
5456
};
5557

5658
export type GetDataForResolverFragmentFn =

packages/relay-runtime/store/ResolverFragments.js

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,22 +111,24 @@ function readFragment(
111111
fragmentSelector.kind === 'SingularReaderSelector',
112112
`Expected a singular reader selector for the fragment of the resolver ${fragmentNode.name}, but it was plural.`,
113113
);
114-
const {data, isMissingData} = context.getDataForResolverFragment(
115-
fragmentSelector,
116-
fragmentKey,
117-
);
118-
119-
if (isMissingData) {
120-
throw RESOLVER_FRAGMENT_MISSING_DATA_SENTINEL;
114+
const {data, isMissingData, missingRequiredFields} =
115+
context.getDataForResolverFragment(fragmentSelector, fragmentKey);
116+
117+
if (
118+
isMissingData ||
119+
(missingRequiredFields != null && missingRequiredFields.action === 'THROW')
120+
// TODO: Also consider @throwOnFieldError
121+
) {
122+
throw RESOLVER_FRAGMENT_ERRORED_SENTINEL;
121123
}
122124

123125
return data;
124126
}
125127

126-
const RESOLVER_FRAGMENT_MISSING_DATA_SENTINEL: mixed = {};
128+
const RESOLVER_FRAGMENT_ERRORED_SENTINEL: mixed = {};
127129

128130
module.exports = {
129131
readFragment,
130132
withResolverContext,
131-
RESOLVER_FRAGMENT_MISSING_DATA_SENTINEL,
133+
RESOLVER_FRAGMENT_ERRORED_SENTINEL,
132134
};

packages/relay-runtime/store/__tests__/RelayReader-Resolver-test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import type {Snapshot} from '../RelayStoreTypes';
1515
const {
1616
constant_dependent: UserConstantDependentResolver,
1717
} = require('./resolvers/UserConstantDependentResolver');
18+
const {
19+
requiredThrowNameCalls,
20+
} = require('./resolvers/UserRequiredThrowNameResolver');
1821
const invariant = require('invariant');
1922
const nullthrows = require('nullthrows');
2023
const {RelayFeatureFlags} = require('relay-runtime');
@@ -348,6 +351,50 @@ describe.each([true, false])(
348351
});
349352
});
350353

354+
it('propagates @required(action: THROW) errors from the resolver up to the reader and avoid calling resolver code', () => {
355+
const source = RelayRecordSource.create({
356+
'client:root': {
357+
__id: 'client:root',
358+
__typename: '__Root',
359+
me: {__ref: '1'},
360+
},
361+
'1': {
362+
__id: '1',
363+
id: '1',
364+
__typename: 'User',
365+
name: null, // The missing field
366+
},
367+
});
368+
const FooQuery = graphql`
369+
query RelayReaderResolverTestRequiredThrowQuery {
370+
me {
371+
required_throw_name
372+
}
373+
}
374+
`;
375+
376+
const operation = createOperationDescriptor(FooQuery, {});
377+
const store = new RelayStore(source, {gcReleaseBufferSize: 0});
378+
379+
const beforeCallCount = requiredThrowNameCalls.count;
380+
const {missingRequiredFields} = store.lookup(operation.fragment);
381+
expect(requiredThrowNameCalls.count).toBe(beforeCallCount);
382+
expect(missingRequiredFields).toEqual({
383+
action: 'THROW',
384+
field: {owner: 'UserRequiredThrowNameResolver', path: 'name'},
385+
});
386+
387+
// Lookup a second time to ensure that we still report the missing fields when
388+
// reading from the cache.
389+
const {missingRequiredFields: missingRequiredFieldsTakeTwo} =
390+
store.lookup(operation.fragment);
391+
392+
expect(missingRequiredFieldsTakeTwo).toEqual({
393+
action: 'THROW',
394+
field: {owner: 'UserRequiredThrowNameResolver', path: 'name'},
395+
});
396+
});
397+
351398
it('works when the field is aliased', () => {
352399
const source = RelayRecordSource.create({
353400
'client:root': {

packages/relay-runtime/store/__tests__/__generated__/RelayReaderResolverTestRequiredThrowQuery.graphql.js

Lines changed: 142 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
* @oncall relay
10+
*/
11+
12+
'use strict';
13+
14+
import type {UserRequiredNameResolver$key} from './__generated__/UserRequiredNameResolver.graphql';
15+
16+
const invariant = require('invariant');
17+
const {graphql} = require('relay-runtime');
18+
const {readFragment} = require('relay-runtime/store/ResolverFragments');
19+
20+
/**
21+
* Represents the number of times the required_name resolver has been called
22+
* and gotten past readFragment.
23+
*/
24+
const requiredThrowNameCalls: {count: number} = {count: 0};
25+
26+
/**
27+
* @RelayResolver User.required_throw_name: String
28+
* @rootFragment UserRequiredThrowNameResolver
29+
*/
30+
function required_name(rootKey: UserRequiredNameResolver$key): string {
31+
const user = readFragment(
32+
graphql`
33+
fragment UserRequiredThrowNameResolver on User {
34+
name @required(action: THROW)
35+
}
36+
`,
37+
rootKey,
38+
);
39+
requiredThrowNameCalls.count++;
40+
invariant(
41+
user != null,
42+
'This error should never throw because the @required should ensure this code never runs',
43+
);
44+
return user.name;
45+
}
46+
47+
module.exports = {
48+
required_name,
49+
requiredThrowNameCalls,
50+
};

0 commit comments

Comments
 (0)