From f5467f2cad7575f9a31c106dd9859c626bbecb8b Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 21 Oct 2022 13:00:35 -0700 Subject: [PATCH] Allow live Resolvers to be batched Differential Revision: D40575460 fbshipit-source-id: b5331afdd115a49d2273d6b571577c50c05f63ad --- .../__tests__/resolvers/LiveResolvers-test.js | 72 ++++++++++ ...lePictureUriSuspendsWhenTheCounterIsOdd.js | 3 +- .../LiveResolversTestBatchingQuery.graphql.js | 132 ++++++++++++++++++ .../LiveResolverCache.js | 50 ++++++- .../LiveResolverStore.js | 23 +++ 5 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 packages/relay-runtime/store/__tests__/resolvers/__generated__/LiveResolversTestBatchingQuery.graphql.js diff --git a/packages/relay-runtime/store/__tests__/resolvers/LiveResolvers-test.js b/packages/relay-runtime/store/__tests__/resolvers/LiveResolvers-test.js index c42ef1e53ca84..9db9ce00cc121 100644 --- a/packages/relay-runtime/store/__tests__/resolvers/LiveResolvers-test.js +++ b/packages/relay-runtime/store/__tests__/resolvers/LiveResolvers-test.js @@ -129,3 +129,75 @@ test('unsubscribe happens when record is updated due to missing data', () => { expect(data.greeting).toBe('Yo user 2'); expect(__debug.state.subscribers.size).toBe(1); }); + +test('Updates can be batched', () => { + const source = RelayRecordSource.create({ + 'client:root': { + __id: 'client:root', + __typename: '__Root', + }, + }); + const operation = createOperationDescriptor( + graphql` + query LiveResolversTestBatchingQuery { + # Together these fields create two subscriptions to the underlying + # GLOBAL_STORE. + counter_no_fragment + counter_no_fragment_with_arg(prefix: "sup") + } + `, + {}, + ); + const store = new LiveResolverStore(source, { + gcReleaseBufferSize: 0, + }); + const environment = new RelayModernEnvironment({ + network: RelayNetwork.create(jest.fn()), + store, + }); + + const snapshot = environment.lookup(operation.fragment); + + const handler = jest.fn(); + environment.subscribe(snapshot, handler); + + expect(handler.mock.calls.length).toBe(0); + + // Update without batching + GLOBAL_STORE.dispatch({type: 'INCREMENT'}); + + // We get notified once per live resolver. :( + expect(handler.mock.calls.length).toBe(2); + + let lastCallCount = handler.mock.calls.length; + + // Update _with_ batching. + store.batchLiveStateUpdates(() => { + GLOBAL_STORE.dispatch({type: 'INCREMENT'}); + }); + + // We get notified once per batch! :) + expect(handler.mock.calls.length - lastCallCount).toBe(1); + + lastCallCount = handler.mock.calls.length; + + // Update with batching, but update throws. + // This might happen if some other subscriber to the store throws when they + // get notified of an error. + expect(() => { + store.batchLiveStateUpdates(() => { + GLOBAL_STORE.dispatch({type: 'INCREMENT'}); + throw new Error('An Example Error'); + }); + }).toThrowError('An Example Error'); + + // We still notify our subscribers + expect(handler.mock.calls.length - lastCallCount).toBe(1); + + // Nested calls to batchLiveStateUpdate throw + expect(() => { + store.batchLiveStateUpdates(() => { + store.batchLiveStateUpdates(() => {}); + }); + }).toThrow('Unexpected nested call to batchLiveStateUpdates.'); +}); diff --git a/packages/relay-runtime/store/__tests__/resolvers/UserProfilePictureUriSuspendsWhenTheCounterIsOdd.js b/packages/relay-runtime/store/__tests__/resolvers/UserProfilePictureUriSuspendsWhenTheCounterIsOdd.js index d0e254b4ab7d8..d572d787b1993 100644 --- a/packages/relay-runtime/store/__tests__/resolvers/UserProfilePictureUriSuspendsWhenTheCounterIsOdd.js +++ b/packages/relay-runtime/store/__tests__/resolvers/UserProfilePictureUriSuspendsWhenTheCounterIsOdd.js @@ -28,7 +28,8 @@ const {readFragment} = require('relay-runtime/store/ResolverFragments'); * @onType User * @live * - * This field is returning the profile picture url, when s + * This field returns the profile picture url, when the GLOBAL_STORE number is + * even and suspends when the number is odd. */ function user_profile_picture_uri_suspends_when_the_counter_is_odd( rootKey: UserProfilePictureUriSuspendsWhenTheCounterIsOdd$key, diff --git a/packages/relay-runtime/store/__tests__/resolvers/__generated__/LiveResolversTestBatchingQuery.graphql.js b/packages/relay-runtime/store/__tests__/resolvers/__generated__/LiveResolversTestBatchingQuery.graphql.js new file mode 100644 index 0000000000000..1979df02560e4 --- /dev/null +++ b/packages/relay-runtime/store/__tests__/resolvers/__generated__/LiveResolversTestBatchingQuery.graphql.js @@ -0,0 +1,132 @@ +/** + * 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 + * @nogrep + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ClientRequest, ClientQuery } from 'relay-runtime'; +import type { LiveState } from "relay-runtime/store/experimental-live-resolvers/LiveResolverStore"; +import {counter_no_fragment as queryCounterNoFragmentResolver} from "../LiveCounterNoFragment.js"; +// Type assertion validating that `queryCounterNoFragmentResolver` resolver is correctly implemented. +// A type error here indicates that the type signature of the resolver module is incorrect. +(queryCounterNoFragmentResolver: () => LiveState); +import {counter_no_fragment_with_arg as queryCounterNoFragmentWithArgResolver} from "../LiveCounterNoFragmentWithArg.js"; +// Type assertion validating that `queryCounterNoFragmentWithArgResolver` resolver is correctly implemented. +// A type error here indicates that the type signature of the resolver module is incorrect. +(queryCounterNoFragmentWithArgResolver: ( + args: {| + prefix: string, + |}, +) => LiveState); +export type LiveResolversTestBatchingQuery$variables = {||}; +export type LiveResolversTestBatchingQuery$data = {| + +counter_no_fragment: ?$Call<$Call<((...empty[]) => R) => R, typeof queryCounterNoFragmentResolver>["read"]>, + +counter_no_fragment_with_arg: ?$Call<$Call<((...empty[]) => R) => R, typeof queryCounterNoFragmentWithArgResolver>["read"]>, +|}; +export type LiveResolversTestBatchingQuery = {| + response: LiveResolversTestBatchingQuery$data, + variables: LiveResolversTestBatchingQuery$variables, +|}; +*/ + +var node/*: ClientRequest*/ = (function(){ +var v0 = [ + { + "kind": "Literal", + "name": "prefix", + "value": "sup" + } +]; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "LiveResolversTestBatchingQuery", + "selections": [ + { + "kind": "ClientExtension", + "selections": [ + { + "alias": null, + "args": null, + "fragment": null, + "kind": "RelayLiveResolver", + "name": "counter_no_fragment", + "resolverModule": require('./../LiveCounterNoFragment').counter_no_fragment, + "path": "counter_no_fragment" + }, + { + "alias": null, + "args": (v0/*: any*/), + "fragment": null, + "kind": "RelayLiveResolver", + "name": "counter_no_fragment_with_arg", + "resolverModule": require('./../LiveCounterNoFragmentWithArg').counter_no_fragment_with_arg, + "path": "counter_no_fragment_with_arg" + } + ] + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "LiveResolversTestBatchingQuery", + "selections": [ + { + "kind": "ClientExtension", + "selections": [ + { + "name": "counter_no_fragment", + "args": null, + "fragment": null, + "kind": "RelayResolver", + "storageKey": null + }, + { + "name": "counter_no_fragment_with_arg", + "args": (v0/*: any*/), + "fragment": null, + "kind": "RelayResolver", + "storageKey": "counter_no_fragment_with_arg(prefix:\"sup\")" + } + ] + } + ] + }, + "params": { + "cacheID": "d286091c8f938beb948d075c767ad6ee", + "id": null, + "metadata": {}, + "name": "LiveResolversTestBatchingQuery", + "operationKind": "query", + "text": null + } +}; +})(); + +if (__DEV__) { + (node/*: any*/).hash = "8610c66cde712c9fa62ec0312041a564"; +} + +module.exports = ((node/*: any*/)/*: ClientQuery< + LiveResolversTestBatchingQuery$variables, + LiveResolversTestBatchingQuery$data, +>*/); diff --git a/packages/relay-runtime/store/experimental-live-resolvers/LiveResolverCache.js b/packages/relay-runtime/store/experimental-live-resolvers/LiveResolverCache.js index 6264568ef9916..fee0c3c292ff2 100644 --- a/packages/relay-runtime/store/experimental-live-resolvers/LiveResolverCache.js +++ b/packages/relay-runtime/store/experimental-live-resolvers/LiveResolverCache.js @@ -85,6 +85,8 @@ class LiveResolverCache implements ResolverCache { _recordIDToResolverIDs: Map>; _getRecordSource: () => MutableRecordSource; _store: LiveResolverStore; + _handlingBatch: boolean; // Flag indicating that Live Resolver updates are being batched. + _liveResolverBatchRecordSource: ?MutableRecordSource; // Lazily created record source for batched Live Resolver updates. constructor( getRecordSource: () => MutableRecordSource, @@ -94,6 +96,8 @@ class LiveResolverCache implements ResolverCache { this._recordIDToResolverIDs = new Map(); this._getRecordSource = getRecordSource; this._store = store; + this._handlingBatch = false; + this._liveResolverBatchRecordSource = null; } readFromCacheOrEvaluate( @@ -403,7 +407,6 @@ class LiveResolverCache implements ResolverCache { return; } - const nextSource = RelayRecordSource.create(); const nextRecord = RelayModernRecord.clone(currentRecord); // Mark the field as dirty. The next time it's read, we will call @@ -414,13 +417,48 @@ class LiveResolverCache implements ResolverCache { true, ); - nextSource.set(linkedID, nextRecord); - this._store.publish(nextSource); + this._setLiveResolverUpdate(linkedID, nextRecord); + }; + } - // In the future, this notify might be deferred if we are within a - // transaction. + _setLiveResolverUpdate(linkedId: DataID, record: Record) { + if (this._handlingBatch) { + // Lazily create the batched record source. + if (this._liveResolverBatchRecordSource == null) { + this._liveResolverBatchRecordSource = RelayRecordSource.create(); + } + this._liveResolverBatchRecordSource.set(linkedId, record); + // We will wait for the batch to complete before we publish/notify... + } else { + const nextSource = RelayRecordSource.create(); + nextSource.set(linkedId, record); + + // We are not within a batch, so we will immediately publish/notify. + this._store.publish(nextSource); this._store.notify(); - }; + } + } + + batchLiveStateUpdates(callback: () => void) { + invariant( + !this._handlingBatch, + 'Unexpected nested call to batchLiveStateUpdates.', + ); + this._handlingBatch = true; + try { + callback(); + } finally { + // We lazily create the record source. If one has not been created, there + // is nothing to publish. + if (this._liveResolverBatchRecordSource != null) { + this._store.publish(this._liveResolverBatchRecordSource); + this._store.notify(); + } + + // Reset batched state. + this._liveResolverBatchRecordSource = null; + this._handlingBatch = false; + } } _setRelayResolverValue( diff --git a/packages/relay-runtime/store/experimental-live-resolvers/LiveResolverStore.js b/packages/relay-runtime/store/experimental-live-resolvers/LiveResolverStore.js index 22facf9f1df15..6a6c52d563800 100644 --- a/packages/relay-runtime/store/experimental-live-resolvers/LiveResolverStore.js +++ b/packages/relay-runtime/store/experimental-live-resolvers/LiveResolverStore.js @@ -191,6 +191,29 @@ class LiveResolverStore implements Store { return this._resolverCache.getLiveResolverPromise(recordID); } + /** + * When an external data proider knows it's going to notify us about multiple + * Live Resolver state updates in a single tick, it can batch them into a + * single Relay update by notifying us within a batch. All updates recieved by + * Relay during the evaluation of the provided `callback` will be aggregated + * into a single Relay update. + * + * A typical use with a Flux store might look like this: + * + * const originalDispatch = fluxStore.dispatch; + * + * function wrapped(action) { + * relayStore.batchLiveStateUpdates(() => { + * originalDispatch(action); + * }) + * } + * + * fluxStore.dispatch = wrapped; + */ + batchLiveStateUpdates(callback: () => void) { + this._resolverCache.batchLiveStateUpdates(callback); + } + check( operation: OperationDescriptor, options?: CheckOptions,