react-relay 0.0.0-main-960835bc → 0.0.0-main-b925743b

Sign up to get free protection for your applications and to get access to all the features.
@@ -14,7 +14,10 @@
14
14
  'use strict';
15
15
 
16
16
  import type {Cache} from './LRUCache';
17
+ import type {QueryResource, QueryResult} from './QueryResource';
17
18
  import type {
19
+ ConcreteRequest,
20
+ DataID,
18
21
  Disposable,
19
22
  IEnvironment,
20
23
  ReaderFragment,
@@ -23,11 +26,17 @@ import type {
23
26
  } from 'relay-runtime';
24
27
 
25
28
  const LRUCache = require('./LRUCache');
29
+ const {getQueryResourceForEnvironment} = require('./QueryResource');
30
+ const SuspenseResource = require('./SuspenseResource');
26
31
  const invariant = require('invariant');
27
32
  const {
33
+ RelayFeatureFlags,
34
+ __internal: {fetchQuery, getPromiseForActiveRequest},
35
+ createOperationDescriptor,
28
36
  getFragmentIdentifier,
29
37
  getPendingOperationsForFragment,
30
38
  getSelector,
39
+ getVariablesFromFragment,
31
40
  isPromise,
32
41
  recycleNodesInto,
33
42
  reportMissingRequiredFields,
@@ -76,6 +85,24 @@ function isMissingData(snapshot: SingularOrPluralSnapshot): boolean {
76
85
  return snapshot.isMissingData;
77
86
  }
78
87
 
88
+ function hasMissingClientEdges(snapshot: SingularOrPluralSnapshot): boolean {
89
+ if (Array.isArray(snapshot)) {
90
+ return snapshot.some(s => (s.missingClientEdges?.length ?? 0) > 0);
91
+ }
92
+ return (snapshot.missingClientEdges?.length ?? 0) > 0;
93
+ }
94
+
95
+ function singularOrPluralForEach(
96
+ snapshot: SingularOrPluralSnapshot,
97
+ f: Snapshot => void,
98
+ ): void {
99
+ if (Array.isArray(snapshot)) {
100
+ snapshot.forEach(f);
101
+ } else {
102
+ f(snapshot);
103
+ }
104
+ }
105
+
79
106
  function getFragmentResult(
80
107
  cacheKey: string,
81
108
  snapshot: SingularOrPluralSnapshot,
@@ -99,13 +126,75 @@ function getFragmentResult(
99
126
  };
100
127
  }
101
128
 
129
+ /**
130
+ * The purpose of this cache is to allow information to be passed from an
131
+ * initial read which suspends through to the commit that follows a subsequent
132
+ * successful read. Specifically, the QueryResource result for the data fetch
133
+ * is passed through so that that query can be retained on commit.
134
+ */
135
+ class ClientEdgeQueryResultsCache {
136
+ _cache: Map<string, [Array<QueryResult>, SuspenseResource]> = new Map();
137
+ _retainCounts: Map<string, number> = new Map();
138
+ _environment: IEnvironment;
139
+
140
+ constructor(environment: IEnvironment) {
141
+ this._environment = environment;
142
+ }
143
+
144
+ get(fragmentIdentifier: string): void | Array<QueryResult> {
145
+ return this._cache.get(fragmentIdentifier)?.[0] ?? undefined;
146
+ }
147
+
148
+ recordQueryResults(
149
+ fragmentIdentifier: string,
150
+ value: Array<QueryResult>, // may be mutated after being passed here
151
+ ): void {
152
+ const existing = this._cache.get(fragmentIdentifier);
153
+ if (!existing) {
154
+ const suspenseResource = new SuspenseResource(() =>
155
+ this._retain(fragmentIdentifier),
156
+ );
157
+ this._cache.set(fragmentIdentifier, [value, suspenseResource]);
158
+ suspenseResource.temporaryRetain(this._environment);
159
+ } else {
160
+ const [existingResults, suspenseResource] = existing;
161
+ value.forEach(queryResult => {
162
+ existingResults.push(queryResult);
163
+ });
164
+ suspenseResource.temporaryRetain(this._environment);
165
+ }
166
+ }
167
+
168
+ _retain(id) {
169
+ const retainCount = (this._retainCounts.get(id) ?? 0) + 1;
170
+ this._retainCounts.set(id, retainCount);
171
+ return {
172
+ dispose: () => {
173
+ const newRetainCount = (this._retainCounts.get(id) ?? 0) - 1;
174
+ if (newRetainCount > 0) {
175
+ this._retainCounts.set(id, newRetainCount);
176
+ } else {
177
+ this._retainCounts.delete(id);
178
+ this._cache.delete(id);
179
+ }
180
+ },
181
+ };
182
+ }
183
+ }
184
+
102
185
  class FragmentResourceImpl {
103
186
  _environment: IEnvironment;
104
187
  _cache: FragmentResourceCache;
188
+ _clientEdgeQueryResultsCache: void | ClientEdgeQueryResultsCache;
105
189
 
106
190
  constructor(environment: IEnvironment) {
107
191
  this._environment = environment;
108
192
  this._cache = LRUCache.create(CACHE_CAPACITY);
193
+ if (RelayFeatureFlags.ENABLE_CLIENT_EDGES) {
194
+ this._clientEdgeQueryResultsCache = new ClientEdgeQueryResultsCache(
195
+ environment,
196
+ );
197
+ }
109
198
  }
110
199
 
111
200
  /**
@@ -250,25 +339,75 @@ class FragmentResourceImpl {
250
339
  return fragmentResult;
251
340
  }
252
341
 
253
- // 3. If we don't have data in the store, check if a request is in
254
- // flight for the fragment's parent query, or for another operation
255
- // that may affect the parent's query data, such as a mutation
256
- // or subscription. If a promise exists, cache the promise and use it
257
- // to suspend.
342
+ // 3. If we don't have data in the store, there's two cases where we should
343
+ // suspend to await the data: First if any client edges were traversed where
344
+ // the destination record was missing data; in that case we initiate a query
345
+ // here to fetch the missing data. Second, there may already be a request
346
+ // in flight for the fragment's parent query, or for another operation that
347
+ // may affect the parent's query data, such as a mutation or subscription.
348
+ // For any of these cases we can get a promise, which we will cache and
349
+ // suspend on.
350
+
351
+ // First, initiate a query for any client edges that were missing data:
352
+ let clientEdgeRequests: ?Array<RequestDescriptor> = null;
353
+ if (
354
+ RelayFeatureFlags.ENABLE_CLIENT_EDGES &&
355
+ hasMissingClientEdges(snapshot)
356
+ ) {
357
+ clientEdgeRequests = [];
358
+ const queryResource = getQueryResourceForEnvironment(this._environment);
359
+ const queryResults = [];
360
+ singularOrPluralForEach(snapshot, snap => {
361
+ snap.missingClientEdges?.forEach(
362
+ ({request, clientEdgeDestinationID}) => {
363
+ const {queryResult, requestDescriptor} =
364
+ this._performClientEdgeQuery(
365
+ queryResource,
366
+ fragmentNode,
367
+ fragmentRef,
368
+ request,
369
+ clientEdgeDestinationID,
370
+ );
371
+ queryResults.push(queryResult);
372
+ clientEdgeRequests?.push(requestDescriptor);
373
+ },
374
+ );
375
+ });
376
+ // Store the query so that it can be retained when our own fragment is
377
+ // subscribed to. This merges with any existing query results:
378
+ invariant(
379
+ this._clientEdgeQueryResultsCache != null,
380
+ 'Client edge query result cache should exist when ENABLE_CLIENT_EDGES is on.',
381
+ );
382
+ this._clientEdgeQueryResultsCache.recordQueryResults(
383
+ fragmentIdentifier,
384
+ queryResults,
385
+ );
386
+ }
387
+ let clientEdgePromises = null;
388
+ if (RelayFeatureFlags.ENABLE_CLIENT_EDGES && clientEdgeRequests) {
389
+ clientEdgePromises = clientEdgeRequests
390
+ .map(request => getPromiseForActiveRequest(this._environment, request))
391
+ .filter(p => p != null);
392
+ }
393
+
394
+ // Finally look for operations in flight for our parent query:
258
395
  const fragmentOwner =
259
396
  fragmentSelector.kind === 'PluralReaderSelector'
260
397
  ? fragmentSelector.selectors[0].owner
261
398
  : fragmentSelector.owner;
262
- const networkPromiseResult =
399
+ const parentQueryPromiseResult =
263
400
  this._getAndSavePromiseForFragmentRequestInFlight(
264
401
  fragmentIdentifier,
265
402
  fragmentNode,
266
403
  fragmentOwner,
267
404
  fragmentResult,
268
405
  );
406
+ const parentQueryPromiseResultPromise = parentQueryPromiseResult?.promise; // for refinement
407
+
269
408
  if (
270
- networkPromiseResult != null &&
271
- isPromise(networkPromiseResult.promise)
409
+ clientEdgePromises?.length ||
410
+ isPromise(parentQueryPromiseResultPromise)
272
411
  ) {
273
412
  environment.__log({
274
413
  name: 'suspense.fragment',
@@ -277,15 +416,52 @@ class FragmentResourceImpl {
277
416
  isRelayHooks: true,
278
417
  isPromiseCached: false,
279
418
  isMissingData: fragmentResult.isMissingData,
280
- pendingOperations: networkPromiseResult.pendingOperations,
419
+ pendingOperations: [
420
+ ...(parentQueryPromiseResult?.pendingOperations ?? []),
421
+ ...(clientEdgeRequests ?? []),
422
+ ],
281
423
  });
282
- throw networkPromiseResult.promise;
424
+ throw clientEdgePromises?.length
425
+ ? Promise.all([parentQueryPromiseResultPromise, ...clientEdgePromises])
426
+ : parentQueryPromiseResultPromise;
283
427
  }
284
428
 
285
429
  this._reportMissingRequiredFieldsInSnapshot(snapshot);
286
430
  return getFragmentResult(fragmentIdentifier, snapshot, storeEpoch);
287
431
  }
288
432
 
433
+ _performClientEdgeQuery(
434
+ queryResource: QueryResource,
435
+ fragmentNode: ReaderFragment,
436
+ fragmentRef: mixed,
437
+ request: ConcreteRequest,
438
+ clientEdgeDestinationID: DataID,
439
+ ) {
440
+ const originalVariables = getVariablesFromFragment(
441
+ fragmentNode,
442
+ fragmentRef,
443
+ );
444
+ const variables = {
445
+ ...originalVariables,
446
+ id: clientEdgeDestinationID, // TODO should be a reserved name
447
+ };
448
+ const operation = createOperationDescriptor(
449
+ request,
450
+ variables,
451
+ {}, // TODO cacheConfig should probably inherent from parent operation
452
+ );
453
+ const fetchObservable = fetchQuery(this._environment, operation);
454
+ const queryResult = queryResource.prepare(
455
+ operation,
456
+ fetchObservable,
457
+ // TODO should inherent render policy etc. from parent operation
458
+ );
459
+ return {
460
+ requestDescriptor: operation.request,
461
+ queryResult,
462
+ };
463
+ }
464
+
289
465
  _reportMissingRequiredFieldsInSnapshot(snapshot: SingularOrPluralSnapshot) {
290
466
  if (Array.isArray(snapshot)) {
291
467
  snapshot.forEach(s => {
@@ -343,7 +519,7 @@ class FragmentResourceImpl {
343
519
  }
344
520
 
345
521
  // 3. Establish subscriptions on the snapshot(s)
346
- const dataSubscriptions = [];
522
+ const disposables = [];
347
523
  if (Array.isArray(renderedSnapshot)) {
348
524
  invariant(
349
525
  Array.isArray(currentSnapshot),
@@ -351,7 +527,7 @@ class FragmentResourceImpl {
351
527
  "If you're seeing this, this is likely a bug in Relay.",
352
528
  );
353
529
  currentSnapshot.forEach((snapshot, idx) => {
354
- dataSubscriptions.push(
530
+ disposables.push(
355
531
  environment.subscribe(snapshot, latestSnapshot => {
356
532
  const storeEpoch = environment.getStore().getEpoch();
357
533
  this._updatePluralSnapshot(
@@ -371,7 +547,7 @@ class FragmentResourceImpl {
371
547
  'Relay: Expected snapshot to be singular. ' +
372
548
  "If you're seeing this, this is likely a bug in Relay.",
373
549
  );
374
- dataSubscriptions.push(
550
+ disposables.push(
375
551
  environment.subscribe(currentSnapshot, latestSnapshot => {
376
552
  const storeEpoch = environment.getStore().getEpoch();
377
553
  this._cache.set(cacheKey, {
@@ -383,9 +559,20 @@ class FragmentResourceImpl {
383
559
  );
384
560
  }
385
561
 
562
+ if (RelayFeatureFlags.ENABLE_CLIENT_EDGES) {
563
+ const clientEdgeQueryResults =
564
+ this._clientEdgeQueryResultsCache?.get(cacheKey) ?? undefined;
565
+ if (clientEdgeQueryResults?.length) {
566
+ const queryResource = getQueryResourceForEnvironment(this._environment);
567
+ clientEdgeQueryResults.forEach(queryResult => {
568
+ disposables.push(queryResource.retain(queryResult));
569
+ });
570
+ }
571
+ }
572
+
386
573
  return {
387
574
  dispose: () => {
388
- dataSubscriptions.map(s => s.dispose());
575
+ disposables.forEach(s => s.dispose());
389
576
  this._cache.delete(cacheKey);
390
577
  },
391
578
  };
@@ -456,6 +643,7 @@ class FragmentResourceImpl {
456
643
  const updatedCurrentSnapshot: Snapshot = {
457
644
  data: updatedData,
458
645
  isMissingData: currentSnapshot.isMissingData,
646
+ missingClientEdges: currentSnapshot.missingClientEdges,
459
647
  seenRecords: currentSnapshot.seenRecords,
460
648
  selector: currentSnapshot.selector,
461
649
  missingRequiredFields: currentSnapshot.missingRequiredFields,
@@ -30,13 +30,13 @@ import type {
30
30
  } from 'relay-runtime';
31
31
 
32
32
  const LRUCache = require('./LRUCache');
33
+ const SuspenseResource = require('./SuspenseResource');
33
34
  const invariant = require('invariant');
34
- const {isPromise} = require('relay-runtime');
35
+ const {RelayFeatureFlags, isPromise} = require('relay-runtime');
35
36
  const warning = require('warning');
36
37
 
37
38
  const CACHE_CAPACITY = 1000;
38
39
  const DEFAULT_FETCH_POLICY = 'store-or-network';
39
- const DATA_RETENTION_TIMEOUT = 5 * 60 * 1000;
40
40
 
41
41
  export type QueryResource = QueryResourceImpl;
42
42
 
@@ -59,7 +59,7 @@ type QueryResourceCacheEntry = {|
59
59
  permanentRetain(environment: IEnvironment): Disposable,
60
60
  releaseTemporaryRetain(): void,
61
61
  |};
62
- opaque type QueryResult: {
62
+ export opaque type QueryResult: {
63
63
  fragmentNode: ReaderFragment,
64
64
  fragmentRef: mixed,
65
65
  ...
@@ -125,6 +125,106 @@ function createCacheEntry(
125
125
  value: Error | Promise<void> | QueryResult,
126
126
  networkSubscription: ?Subscription,
127
127
  onDispose: QueryResourceCacheEntry => void,
128
+ ): QueryResourceCacheEntry {
129
+ // There should be no behavior difference between createCacheEntry_new and
130
+ // createCacheEntry_old, and it doesn't directly relate to Client Edges.
131
+ // It was just a refactoring that was needed for Client Edges but that
132
+ // is behind the feature flag just in case there is any accidental breakage.
133
+ if (RelayFeatureFlags.REFACTOR_SUSPENSE_RESOURCE) {
134
+ return createCacheEntry_new(
135
+ cacheIdentifier,
136
+ operation,
137
+ operationAvailability,
138
+ value,
139
+ networkSubscription,
140
+ onDispose,
141
+ );
142
+ } else {
143
+ return createCacheEntry_old(
144
+ cacheIdentifier,
145
+ operation,
146
+ operationAvailability,
147
+ value,
148
+ networkSubscription,
149
+ onDispose,
150
+ );
151
+ }
152
+ }
153
+
154
+ function createCacheEntry_new(
155
+ cacheIdentifier: string,
156
+ operation: OperationDescriptor,
157
+ operationAvailability: ?OperationAvailability,
158
+ value: Error | Promise<void> | QueryResult,
159
+ networkSubscription: ?Subscription,
160
+ onDispose: QueryResourceCacheEntry => void,
161
+ ): QueryResourceCacheEntry {
162
+ const isLiveQuery = operationIsLiveQuery(operation);
163
+
164
+ let currentValue: Error | Promise<void> | QueryResult = value;
165
+ let currentNetworkSubscription: ?Subscription = networkSubscription;
166
+
167
+ const suspenseResource = new SuspenseResource(environment => {
168
+ const retention = environment.retain(operation);
169
+ return {
170
+ dispose: () => {
171
+ // Normally if this entry never commits, the request would've ended by the
172
+ // time this timeout expires and the temporary retain is released. However,
173
+ // we need to do this for live queries which remain open indefinitely.
174
+ if (isLiveQuery && currentNetworkSubscription != null) {
175
+ currentNetworkSubscription.unsubscribe();
176
+ }
177
+ retention.dispose();
178
+ onDispose(cacheEntry);
179
+ },
180
+ };
181
+ });
182
+
183
+ const cacheEntry = {
184
+ cacheIdentifier,
185
+ id: nextID++,
186
+ processedPayloadsCount: 0,
187
+ operationAvailability,
188
+ getValue() {
189
+ return currentValue;
190
+ },
191
+ setValue(val: QueryResult | Promise<void> | Error) {
192
+ currentValue = val;
193
+ },
194
+ getRetainCount() {
195
+ return suspenseResource.getRetainCount();
196
+ },
197
+ getNetworkSubscription() {
198
+ return currentNetworkSubscription;
199
+ },
200
+ setNetworkSubscription(subscription: ?Subscription) {
201
+ if (isLiveQuery && currentNetworkSubscription != null) {
202
+ currentNetworkSubscription.unsubscribe();
203
+ }
204
+ currentNetworkSubscription = subscription;
205
+ },
206
+ temporaryRetain(environment: IEnvironment): Disposable {
207
+ return suspenseResource.temporaryRetain(environment);
208
+ },
209
+ permanentRetain(environment: IEnvironment): Disposable {
210
+ return suspenseResource.permanentRetain(environment);
211
+ },
212
+ releaseTemporaryRetain() {
213
+ suspenseResource.releaseTemporaryRetain();
214
+ },
215
+ };
216
+
217
+ return cacheEntry;
218
+ }
219
+
220
+ const DATA_RETENTION_TIMEOUT = 5 * 60 * 1000;
221
+ function createCacheEntry_old(
222
+ cacheIdentifier: string,
223
+ operation: OperationDescriptor,
224
+ operationAvailability: ?OperationAvailability,
225
+ value: Error | Promise<void> | QueryResult,
226
+ networkSubscription: ?Subscription,
227
+ onDispose: QueryResourceCacheEntry => void,
128
228
  ): QueryResourceCacheEntry {
129
229
  const isLiveQuery = operationIsLiveQuery(operation);
130
230
 
@@ -432,8 +532,14 @@ class QueryResourceImpl {
432
532
  }
433
533
 
434
534
  _clearCacheEntry = (cacheEntry: QueryResourceCacheEntry): void => {
435
- if (cacheEntry.getRetainCount() <= 0) {
535
+ // The new code does this retainCount <= 0 check within SuspenseResource
536
+ // before calling _clearCacheEntry, whereas with the old code we do it here.
537
+ if (RelayFeatureFlags.REFACTOR_SUSPENSE_RESOURCE) {
436
538
  this._cache.delete(cacheEntry.cacheIdentifier);
539
+ } else {
540
+ if (cacheEntry.getRetainCount() <= 0) {
541
+ this._cache.delete(cacheEntry.cacheIdentifier);
542
+ }
437
543
  }
438
544
  };
439
545
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its 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
+ * @emails oncall+relay
9
+ * @format
10
+ */
11
+
12
+ // flowlint ambiguous-object-type:error
13
+
14
+ 'use strict';
15
+
16
+ import type {Disposable, IEnvironment} from 'relay-runtime';
17
+
18
+ const invariant = require('invariant');
19
+
20
+ const TEMPORARY_RETAIN_DURATION_MS = 5 * 60 * 1000;
21
+
22
+ /**
23
+ * Allows you to retain a resource as part of a component lifecycle accounting
24
+ * for Suspense. You temporarily retain the resource during render, then
25
+ * permanently retain it during commit and release it during unmount.
26
+ */
27
+ class SuspenseResource {
28
+ _retainCount = 0;
29
+ _retainDisposable: ?Disposable = null;
30
+ _releaseTemporaryRetain: ?() => void = null;
31
+ _retain: IEnvironment => Disposable;
32
+
33
+ constructor(retain: (environment: IEnvironment) => Disposable) {
34
+ this._retain = (environment: IEnvironment): Disposable => {
35
+ this._retainCount++;
36
+ if (this._retainCount === 1) {
37
+ this._retainDisposable = retain(environment);
38
+ }
39
+ return {
40
+ dispose: () => {
41
+ this._retainCount = Math.max(0, this._retainCount - 1);
42
+ if (this._retainCount === 0) {
43
+ invariant(
44
+ this._retainDisposable != null,
45
+ 'Relay: Expected disposable to release query to be defined.' +
46
+ "If you're seeing this, this is likely a bug in Relay.",
47
+ );
48
+ this._retainDisposable.dispose();
49
+ this._retainDisposable = null;
50
+ }
51
+ },
52
+ };
53
+ };
54
+ }
55
+
56
+ temporaryRetain(environment: IEnvironment): Disposable {
57
+ // If we're executing in a server environment, there's no need
58
+ // to create temporary retains, since the component will never commit.
59
+ if (environment.isServer()) {
60
+ return {dispose: () => {}};
61
+ }
62
+
63
+ // temporaryRetain is called during the render phase. However,
64
+ // given that we can't tell if this render will eventually commit or not,
65
+ // we create a timer to autodispose of this retain in case the associated
66
+ // component never commits.
67
+ // If the component /does/ commit, permanentRetain will clear this timeout
68
+ // and permanently retain the data.
69
+ const retention = this._retain(environment);
70
+ let releaseQueryTimeout = null;
71
+ const releaseTemporaryRetain = () => {
72
+ clearTimeout(releaseQueryTimeout);
73
+ releaseQueryTimeout = null;
74
+ this._releaseTemporaryRetain = null;
75
+ retention.dispose();
76
+ };
77
+ releaseQueryTimeout = setTimeout(
78
+ releaseTemporaryRetain,
79
+ TEMPORARY_RETAIN_DURATION_MS,
80
+ );
81
+
82
+ // NOTE: Since temporaryRetain can be called multiple times, we release
83
+ // the previous temporary retain after we re-establish a new one, since
84
+ // we only ever need a single temporary retain until the permanent retain is
85
+ // established.
86
+ // temporaryRetain may be called multiple times by React during the render
87
+ // phase, as well as multiple times by other query components that are
88
+ // rendering the same query/variables.
89
+ this._releaseTemporaryRetain?.();
90
+ this._releaseTemporaryRetain = releaseTemporaryRetain;
91
+
92
+ return {
93
+ dispose: () => {
94
+ this._releaseTemporaryRetain?.();
95
+ },
96
+ };
97
+ }
98
+
99
+ permanentRetain(environment: IEnvironment): Disposable {
100
+ const disposable = this._retain(environment);
101
+ this.releaseTemporaryRetain();
102
+ return disposable;
103
+ }
104
+
105
+ releaseTemporaryRetain(): void {
106
+ this._releaseTemporaryRetain?.();
107
+ this._releaseTemporaryRetain = null;
108
+ }
109
+
110
+ getRetainCount(): number {
111
+ return this._retainCount;
112
+ }
113
+ }
114
+
115
+ module.exports = SuspenseResource;