Skip to content

Commit 1e60e47

Browse files
subtleGradientfacebook-github-bot
authored andcommitted
unbreak LiveState unsub when references.size === 0 (#4832)
Summary: fixes #4830 by ensuring that there is only a single path for evicting stuff, ensuring that `maybeResolverSubscription` gets called correctly Pull Request resolved: #4832 Reviewed By: lynnshaoyu Differential Revision: D66391741 Pulled By: captbaritone fbshipit-source-id: a33995d52b4fc61b46bf48513e4a2c5ad306a486
1 parent 524a059 commit 1e60e47

File tree

3 files changed

+208
-26
lines changed

3 files changed

+208
-26
lines changed

‎packages/relay-runtime/store/RelayModernStore.js

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -764,36 +764,36 @@ class RelayModernStore implements Store {
764764
}
765765
}
766766

767-
// Sweep records without references
768-
if (references.size === 0) {
769-
// Short-circuit if *nothing* is referenced
770-
this._recordSource.clear();
771-
} else {
772-
// Evict any unreferenced nodes
773-
const storeIDs = this._recordSource.getRecordIDs();
774-
for (let ii = 0; ii < storeIDs.length; ii++) {
775-
const dataID = storeIDs[ii];
776-
if (!references.has(dataID)) {
777-
const record = this._recordSource.get(dataID);
778-
if (record != null) {
779-
const maybeResolverSubscription = RelayModernRecord.getValue(
780-
record,
781-
RELAY_RESOLVER_LIVE_STATE_SUBSCRIPTION_KEY,
782-
);
783-
if (maybeResolverSubscription != null) {
784-
// $FlowFixMe - this value if it is not null, it is a function
785-
maybeResolverSubscription();
786-
}
787-
}
788-
this._recordSource.remove(dataID);
789-
if (this._shouldRetainWithinTTL_EXPERIMENTAL) {
790-
// Note: A record that was never retained will not be in the roots map
791-
// but the following line should not throw
792-
this._roots.delete(dataID);
767+
// NOTE: It may be tempting to use `this._recordSource.clear()`
768+
// when no references are found, but that would prevent calling
769+
// maybeResolverSubscription() on any records that have an active
770+
// resolver subscription. This would result in a memory leak.
771+
772+
// Evict any unreferenced nodes
773+
const storeIDs = this._recordSource.getRecordIDs();
774+
for (let ii = 0; ii < storeIDs.length; ii++) {
775+
const dataID = storeIDs[ii];
776+
if (!references.has(dataID)) {
777+
const record = this._recordSource.get(dataID);
778+
if (record != null) {
779+
const maybeResolverSubscription = RelayModernRecord.getValue(
780+
record,
781+
RELAY_RESOLVER_LIVE_STATE_SUBSCRIPTION_KEY,
782+
);
783+
if (maybeResolverSubscription != null) {
784+
// $FlowFixMe - this value if it is not null, it is a function
785+
maybeResolverSubscription();
793786
}
794787
}
788+
this._recordSource.remove(dataID);
789+
if (this._shouldRetainWithinTTL_EXPERIMENTAL) {
790+
// Note: A record that was never retained will not be in the roots map
791+
// but the following line should not throw
792+
this._roots.delete(dataID);
793+
}
795794
}
796795
}
796+
797797
if (log != null) {
798798
log({
799799
name: 'store.gc.end',

‎packages/relay-runtime/store/__tests__/resolvers/ResolverGC-test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,85 @@ test('Resolver reading a client-edge to a client type (recursive)', async () =>
827827
});
828828
});
829829

830+
test.each([0, 1, 5])(
831+
'Live Resolver cleanup when %i references retained',
832+
async numRetainedReferences => {
833+
const unsubscribeMock = jest.fn();
834+
const subscribeSpy = jest
835+
.spyOn(GLOBAL_STORE, 'subscribe')
836+
.mockImplementation(() => {
837+
return unsubscribeMock;
838+
});
839+
840+
// Reset the store before each test run
841+
resetStore();
842+
843+
const source = RelayRecordSource.create();
844+
845+
const store = new RelayModernStore(source, {
846+
gcReleaseBufferSize: 0,
847+
});
848+
849+
const environment = new RelayModernEnvironment({
850+
network: RelayNetwork.create((request, variables) => {
851+
return Promise.resolve({data: {}});
852+
}),
853+
store,
854+
});
855+
856+
// The operation that uses the live resolver
857+
const operation = createOperationDescriptor(
858+
graphql`
859+
query ResolverGCTestNoRetainedQueriesQuery {
860+
counter_no_fragment
861+
}
862+
`,
863+
{},
864+
);
865+
866+
// Execute the query to populate the store
867+
await environment.execute({operation}).toPromise();
868+
869+
// Lookup the data to trigger evaluation of the resolver
870+
const snapshot = environment.lookup(operation.fragment);
871+
872+
// Ensure the live resolver has been called
873+
expect(subscribeSpy).toHaveBeenCalledTimes(1);
874+
expect(snapshot.data).toEqual({counter_no_fragment: 0});
875+
876+
// Retain the operation if numRetainedReferences > 0
877+
const retains = [];
878+
for (let i = 0; i < numRetainedReferences; i++) {
879+
retains.push(environment.retain(operation));
880+
}
881+
882+
// Run GC
883+
store.__gc();
884+
885+
if (numRetainedReferences > 0) {
886+
// The data is still retained, so cleanup should not have happened
887+
expect(unsubscribeMock).not.toHaveBeenCalled();
888+
} else {
889+
// The data is not retained, cleanup should have happened
890+
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
891+
}
892+
893+
// Dispose of the retains
894+
for (const retain of retains) {
895+
retain.dispose();
896+
}
897+
898+
// Run GC again to ensure cleanup happens after disposing retains
899+
store.__gc();
900+
901+
// Now, cleanup should have happened if it didn't before
902+
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
903+
904+
// Cleanup the spy
905+
subscribeSpy.mockRestore();
906+
},
907+
);
908+
830909
type TestProps<T: OperationType> = {
831910
query: ConcreteRequest,
832911
variables: VariablesOf<T>,

‎packages/relay-runtime/store/__tests__/resolvers/__generated__/ResolverGCTestNoRetainedQueriesQuery.graphql.js

Lines changed: 103 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)