react-relay 0.0.0-main-f0fc1eea → 0.0.0-main-c454f22f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;