Skip to content

Commit a21b1cb

Browse files
Juan Tejadafacebook-github-bot
Juan Tejada
authored andcommittedAug 21, 2020
Make sure loadQuery requests are deduped when started (vs at render time)
Reviewed By: josephsavona Differential Revision: D23171834 fbshipit-source-id: e368f913150ea1aecac92cd66c0d7083635671a2
1 parent 0c31374 commit a21b1cb

File tree

6 files changed

+153
-76
lines changed

6 files changed

+153
-76
lines changed
 

‎packages/relay-experimental/__tests__/loadQuery-source-behavior-test.js

+29-2
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ const ID = '12345';
6969
const variables = {id: '4'};
7070

7171
let callLoadQuery;
72+
let environment;
73+
let fetch;
7274
let writeDataToStore;
7375
let sink;
7476
let next;
@@ -77,19 +79,20 @@ let error;
7779
beforeEach(() => {
7880
PreloadableQueryRegistry.clear();
7981

80-
const fetch = jest.fn((_query, _variables, _cacheConfig) => {
82+
fetch = jest.fn((_query, _variables, _cacheConfig) => {
8183
return Observable.create(_sink => {
8284
sink = _sink;
8385
});
8486
});
8587

86-
const environment = createMockEnvironment({network: Network.create(fetch)});
88+
environment = createMockEnvironment({network: Network.create(fetch)});
8789
const store = environment.getStore();
8890
const operation = createOperationDescriptor(query, variables);
8991

9092
writeDataToStore = () => {
9193
loadQuery(environment, preloadableConcreteRequest, variables);
9294
sink.next(response);
95+
sink.complete();
9396
PreloadableQueryRegistry.set(ID, query);
9497
expect(store.check(operation).status).toBe('available');
9598
// N.B. we are not testing the case where data is written to the store
@@ -134,6 +137,14 @@ describe('when passed a PreloadableConcreteRequest', () => {
134137
expect(next).toHaveBeenCalledWith(response);
135138
});
136139

140+
it('should dedupe network request if called multiple times', () => {
141+
PreloadableQueryRegistry.set(ID, query);
142+
callLoadQuery(preloadableConcreteRequest);
143+
callLoadQuery(preloadableConcreteRequest);
144+
145+
expect(fetch).toHaveBeenCalledTimes(1);
146+
});
147+
137148
it('should pass network errors onto source', () => {
138149
PreloadableQueryRegistry.set(ID, query);
139150
callLoadQuery(preloadableConcreteRequest);
@@ -164,6 +175,22 @@ describe('when passed a PreloadableConcreteRequest', () => {
164175
});
165176

166177
describe('when the query is unavailable synchronously', () => {
178+
it('should dedupe operation execution if called multiple times', () => {
179+
callLoadQuery(preloadableConcreteRequest);
180+
callLoadQuery(preloadableConcreteRequest);
181+
182+
// Note: before we have the operation module is available
183+
// we can't reliably dedupe network requests, since the
184+
// request identifier is based on the variables the
185+
// operation expects, and not just the variables passed as
186+
// input.
187+
expect(fetch).toHaveBeenCalledTimes(2);
188+
189+
PreloadableQueryRegistry.set(ID, query);
190+
// We only process the network request once.
191+
expect(environment.executeWithSource).toBeCalledTimes(1);
192+
});
193+
167194
describe('when the query AST is available before the network response', () => {
168195
it('should pass network responses onto source', () => {
169196
callLoadQuery(preloadableConcreteRequest);

‎packages/relay-experimental/__tests__/loadQuery-store-behavior-test.js

+1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ beforeEach(() => {
104104
writeDataToStore = () => {
105105
loadQuery(environment, preloadableConcreteRequest, variables);
106106
sink.next(response);
107+
sink.complete();
107108
PreloadableQueryRegistry.set(ID, query);
108109
expect(store.check(operation).status).toBe('available');
109110
const snapshot: $FlowFixMe = store.lookup(operation.fragment);

‎packages/relay-experimental/__tests__/loadQuery-test.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,10 @@ beforeEach(() => {
9999
return {dispose: disposeOnloadCallback};
100100
});
101101

102-
const originalExecuteWithSource = environment.executeWithSource.bind(
103-
environment,
104-
);
102+
const originalExecuteWithSource = environment.executeWithSource.getMockImplementation();
105103
executeObservable = undefined;
106104
executeUnsubscribe = undefined;
105+
107106
jest
108107
.spyOn(environment, 'executeWithSource')
109108
.mockImplementation((...params) => {

‎packages/relay-experimental/loadQuery.js

+89-35
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
createOperationDescriptor,
2222
getRequest,
2323
Observable,
24+
__internal: {fetchQueryDeduped},
2425
} = require('relay-runtime');
2526

2627
import type {
@@ -30,6 +31,7 @@ import type {
3031
} from './EntryPointTypes.flow';
3132
import type {
3233
IEnvironment,
34+
OperationDescriptor,
3335
OperationType,
3436
GraphQLTaggedNode,
3537
GraphQLResponse,
@@ -57,8 +59,7 @@ function loadQuery<TQuery: OperationType, TEnvironmentProviderOptions>(
5759
options?: LoadQueryOptions,
5860
environmentProviderOptions?: TEnvironmentProviderOptions,
5961
): PreloadedQueryInner<TQuery, TEnvironmentProviderOptions> {
60-
// Flow does not know of React internals (rightly so), but we need to
61-
// ensure here that this function isn't called inside render.
62+
// This code ensures that we don't call loadQuery during render.
6263
const CurrentDispatcher =
6364
// $FlowFixMe[prop-missing]
6465
React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
@@ -74,6 +75,9 @@ function loadQuery<TQuery: OperationType, TEnvironmentProviderOptions>(
7475
force: true,
7576
};
7677

78+
// makeNetworkRequest will immediately start a raw network request and
79+
// return an Observable that when subscribing to it, will replay the
80+
// network events that have occured so far, as well as subsequent events.
7781
let madeNetworkRequest = false;
7882
const makeNetworkRequest = (params): Observable<GraphQLResponse> => {
7983
// N.B. this function is called synchronously or not at all
@@ -102,45 +106,90 @@ function loadQuery<TQuery: OperationType, TEnvironmentProviderOptions>(
102106
return Observable.create(sink => subject.subscribe(sink));
103107
};
104108

105-
const normalizationSubject = new ReplaySubject();
109+
// executeWithNetworkSource will retain and execute an operation
110+
// against the Relay store, given an Observable that would provide
111+
// the network events for the operation.
112+
let retainReference;
113+
const executeWithNetworkSource = (
114+
operation: OperationDescriptor,
115+
networkObservable: Observable<GraphQLResponse>,
116+
): Observable<GraphQLResponse> => {
117+
retainReference = environment.retain(operation);
118+
return environment.executeWithSource({
119+
operation,
120+
source: networkObservable,
121+
});
122+
};
123+
124+
// N.B. For loadQuery, we unconventionally want to return an Observable
125+
// that isn't lazily executed, meaning that we don't want to wait
126+
// until the returned Observable is subscribed to to actually start
127+
// fetching and executing an operation; i.e. we want to execute the
128+
// operation eagerly, when loadQuery is called.
129+
// For this reason, we use an intermediate executionSubject which
130+
// allows us to capture the events that occur during the eager execution
131+
// of the operation, and then replay them to the Observable we
132+
// ultimately return.
133+
const executionSubject = new ReplaySubject();
106134
const returnedObservable = Observable.create(sink =>
107-
normalizationSubject.subscribe(sink),
135+
executionSubject.subscribe(sink),
108136
);
109137

110-
let unsubscribeFromExecute;
111-
let retainReference;
112-
const executeWithSource = (operation, sourceObservable) => {
113-
retainReference = environment.retain(operation);
114-
({unsubscribe: unsubscribeFromExecute} = environment
115-
.executeWithSource({
116-
operation,
117-
source: sourceObservable,
118-
})
119-
.subscribe({
120-
error(err) {
121-
normalizationSubject.error(err);
122-
},
123-
next(data) {
124-
normalizationSubject.next(data);
125-
},
126-
complete() {
127-
normalizationSubject.complete();
128-
},
129-
}));
138+
let unsubscribeFromExecution;
139+
const executeDeduped = (
140+
operation: OperationDescriptor,
141+
fetchFn: () => Observable<GraphQLResponse>,
142+
) => {
143+
// N.B.
144+
// Here, we are calling fetchQueryDeduped, which ensures that only
145+
// a single operation is active for a given (environment, identifier) pair,
146+
// and also tracks the active state of the operation, which is necessary
147+
// for our Suspense infra to later be able to suspend (or not) on
148+
// active operations.
149+
// - If a duplicate active operation is found, it will return an
150+
// Observable that replays the events of the already active operation.
151+
// - If no duplicate active operation is found, it will call the fetchFn
152+
// to execute the operation, and return an Observable that will provide
153+
// the events for executing the operation.
154+
({unsubscribe: unsubscribeFromExecution} = fetchQueryDeduped(
155+
environment,
156+
operation.request.identifier,
157+
fetchFn,
158+
).subscribe({
159+
error(err) {
160+
executionSubject.error(err);
161+
},
162+
next(data) {
163+
executionSubject.next(data);
164+
},
165+
complete() {
166+
executionSubject.complete();
167+
},
168+
}));
130169
};
131170

132171
const checkAvailabilityAndExecute = concreteRequest => {
133172
const operation = createOperationDescriptor(concreteRequest, variables);
173+
174+
// N.B. If the fetch policy allows fulfillment from the store but the
175+
// environment already has the data for that operation cached in the store,
176+
// then we do nothing.
134177
const shouldFetch =
135178
fetchPolicy !== 'store-or-network' ||
136179
environment.check(operation).status !== 'available';
137180

138181
if (shouldFetch) {
139-
const source = makeNetworkRequest(concreteRequest.params);
140-
executeWithSource(operation, source);
182+
executeDeduped(operation, () => {
183+
// N.B. Since we have the operation synchronously available here,
184+
// we can immediately fetch and execute the operation.
185+
const networkObservable = makeNetworkRequest(concreteRequest.params);
186+
const executeObservable = executeWithNetworkSource(
187+
operation,
188+
networkObservable,
189+
);
190+
return executeObservable;
191+
});
141192
}
142-
// if the fetch policy allows fulfillment from the store and the environment
143-
// has the appropriate data, we do nothing.
144193
};
145194

146195
let params;
@@ -163,9 +212,13 @@ function loadQuery<TQuery: OperationType, TEnvironmentProviderOptions>(
163212
if (module != null) {
164213
checkAvailabilityAndExecute(module);
165214
} else {
166-
// If the module isn't synchronously available, we launch the network request
167-
// immediately and ignore the fetch policy.
168-
const source = makeNetworkRequest(params);
215+
// If the module isn't synchronously available, we launch the
216+
// network request immediately and ignore the fetch policy.
217+
// Note that without the operation module we can't reliably
218+
// dedupe network requests, since the request identifier is
219+
// based on the variables the operation expects, and not
220+
// just the variables passed as input.
221+
const networkObservable = makeNetworkRequest(params);
169222
({dispose: cancelOnLoadCallback} = PreloadableQueryRegistry.onLoad(
170223
moduleId,
171224
preloadedModule => {
@@ -175,7 +228,9 @@ function loadQuery<TQuery: OperationType, TEnvironmentProviderOptions>(
175228
preloadedModule,
176229
variables,
177230
);
178-
executeWithSource(operation, source);
231+
executeDeduped(operation, () =>
232+
executeWithNetworkSource(operation, networkObservable),
233+
);
179234
},
180235
));
181236
if (!environment.isServer()) {
@@ -187,15 +242,14 @@ function loadQuery<TQuery: OperationType, TEnvironmentProviderOptions>(
187242
}
188243
// complete() the subject so that the observer knows no (additional) payloads
189244
// will be delivered
190-
normalizationSubject.complete();
245+
executionSubject.complete();
191246
}, LOAD_QUERY_AST_MAX_TIMEOUT);
192247
}
193248
}
194249
} else {
195250
const graphQlTaggedNode: GraphQLTaggedNode = (preloadableRequest: $FlowFixMe);
196251
const request = getRequest(graphQlTaggedNode);
197252
params = request.params;
198-
199253
checkAvailabilityAndExecute(request);
200254
}
201255

@@ -208,7 +262,7 @@ function loadQuery<TQuery: OperationType, TEnvironmentProviderOptions>(
208262
if (isDisposed) {
209263
return;
210264
}
211-
unsubscribeFromExecute && unsubscribeFromExecute();
265+
unsubscribeFromExecution && unsubscribeFromExecution();
212266
retainReference && retainReference.dispose();
213267
cancelOnLoadCallback && cancelOnLoadCallback();
214268
loadQueryAstTimeoutId != null && clearTimeout(loadQueryAstTimeoutId);

‎packages/relay-experimental/usePreloadedQuery.js

+31-36
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const {useTrackLoadQueryInRender} = require('./loadQuery');
2323
const {useDebugValue} = require('react');
2424
const {
2525
__internal: {fetchQueryDeduped},
26-
Observable,
2726
} = require('relay-runtime');
2827

2928
import type {PreloadedQuery} from './EntryPointTypes.flow';
@@ -85,47 +84,43 @@ function usePreloadedQuery<TQuery: OperationType>(
8584
'collection, and as such query results may no longer be present in the Relay ' +
8685
'store. In the future, this will become a hard error.',
8786
);
88-
// Here, we are calling fetchQueryDeduped, which usually ensures that only
89-
// a single network request is active for a given (environment, identifier) pair.
90-
// If no network request is active, it will call the third argument and initiate
91-
// a network request.
92-
//
93-
// However, if preloadedQuery.kind === 'PreloadedQuery', the network request (if
94-
// it exists) has already been made.
95-
//
96-
// Thus, if two calls to loadQuery are made with the same environment and identifier
97-
// (i.e. the same request is made twice), the second query will be deduped
98-
// and components will suspend for the duration of the first query.
99-
const dedupedSource = fetchQueryDeduped(
87+
88+
const fallbackFetchObservable = fetchQueryDeduped(
10089
environment,
10190
operation.request.identifier,
102-
() => {
103-
if (source && environment === preloadedQuery.environment) {
104-
return source.ifEmpty(
105-
Observable.create(sink => {
106-
return environment.execute({operation}).subscribe(sink);
107-
}),
108-
);
109-
} else {
110-
// if a call to loadQuery is made with a particular environment, and that
111-
// preloaded query is passed to usePreloadedQuery in a different environmental
112-
// context, we cannot re-use the existing preloaded query. Instead, we must
113-
// re-execute the query with the new environment (at render time.)
114-
// TODO T68036756 track occurences of this warning and turn it into a hard error
115-
warning(
116-
false,
117-
'usePreloadedQuery(): usePreloadedQuery was passed a preloaded query ' +
118-
'that was created with a different environment than the one that is currently ' +
119-
'in context. In the future, this will become a hard error.',
120-
);
121-
return environment.execute({operation});
122-
}
123-
},
91+
() => environment.execute({operation}),
12492
);
93+
let fetchObservable;
94+
if (source != null && environment === preloadedQuery.environment) {
95+
// If the source observable exists and the environments match, reuse
96+
// the source observable.
97+
// If the source observable happens to be empty, we need to fall back
98+
// and re-execute and de-dupe the query (at render time).
99+
fetchObservable = source.ifEmpty(fallbackFetchObservable);
100+
} else if (environment !== preloadedQuery.environment) {
101+
// If a call to loadQuery is made with a particular environment, and that
102+
// preloaded query is passed to usePreloadedQuery in a different environment
103+
// context, we cannot re-use the existing preloaded query.
104+
// Instead, we need to fall back and re-execute and de-dupe the query with
105+
// the new environment (at render time).
106+
// TODO T68036756 track occurences of this warning and turn it into a hard error
107+
warning(
108+
false,
109+
'usePreloadedQuery(): usePreloadedQuery was passed a preloaded query ' +
110+
'that was created with a different environment than the one that is currently ' +
111+
'in context. In the future, this will become a hard error.',
112+
);
113+
fetchObservable = fallbackFetchObservable;
114+
} else {
115+
// if (source == null)
116+
// If the source observable does not exist, we need to
117+
// fall back and re-execute and de-dupe the query (at render time).
118+
fetchObservable = fallbackFetchObservable;
119+
}
125120

126121
useLazyLoadQueryNodeParams = {
127122
componentDisplayName: 'usePreloadedQuery()',
128-
fetchObservable: dedupedSource,
123+
fetchObservable,
129124
fetchPolicy,
130125
query: operation,
131126
renderPolicy: options?.UNSTABLE_renderPolicy,

‎packages/relay-test-utils/RelayModernMockEnvironment.js

+1
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ function createMockEnvironment(
480480
mockDisposableMethod(environment, 'subscribe');
481481
mockDisposableMethod(environment, 'retain');
482482
mockObservableMethod(environment, 'execute');
483+
mockObservableMethod(environment, 'executeWithSource');
483484
mockObservableMethod(environment, 'executeMutation');
484485

485486
mockInstanceMethod(store, 'getSource');

0 commit comments

Comments
 (0)
Please sign in to comment.