Home Reference Source Repository

src/store/__tests__/RelayGarbageCollector-test.js

/**
 * Copyright (c) 2013-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 *
 * @emails oncall+relay
 */

'use strict';

require('configureForRelayOSS');

jest
  .unmock('GraphQLRange')
  .unmock('GraphQLSegment');

const Relay = require('Relay');
const RelayNodeInterface = require('RelayNodeInterface');
const RelayStoreData = require('RelayStoreData');
const RelayTestUtils = require('RelayTestUtils');

const forEachObject = require('forEachObject');
const transformRelayQueryPayload = require('transformRelayQueryPayload');

describe('RelayGarbageCollector', () => {
  const {getNode} = RelayTestUtils;
  const {HAS_NEXT_PAGE, HAS_PREV_PAGE, PAGE_INFO} = RelayNodeInterface;

  function defaultScheduler(run) {
    // collect everything without pausing
    while (run()) {}
  }

  function createGC(records, scheduler) {
    scheduler = scheduler || defaultScheduler;

    const storeData = new RelayStoreData();
    storeData.initializeGarbageCollector(scheduler);
    const nodeData = storeData.getNodeData();
    if (records) {
      forEachObject(records, (data, dataID) => {
        nodeData[dataID] = data;
      });
    }

    return {
      garbageCollector: storeData.getGarbageCollector(),
      storeData,
    };
  }

  beforeEach(() => {
    jest.resetModuleRegistry();
    jasmine.addMatchers(RelayTestUtils.matchers);
  });

  describe('collect()', () => {
    it('collects all unreferenced nodes', () => {
      const records = {
        referenced: {__dataID__: 'referenced'},
        unreferenced: {__dataID__: 'unreferenced'},
      };
      const {garbageCollector, storeData} = createGC(records);
      garbageCollector.register('unreferenced');
      garbageCollector.incrementReferenceCount('unreferenced');
      garbageCollector.decrementReferenceCount('unreferenced');
      garbageCollector.register('referenced');
      garbageCollector.incrementReferenceCount('referenced');
      garbageCollector.collect();
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual({
        referenced: records.referenced,
      });
    });
  });

  describe('collectFromNode()', () => {
    it('collects reachable unreferenced nodes', () => {
      const records = {
        a: {
          __dataID__: 'a',
          field: {__dataID__: 'b'},
        },
        b: {
          __dataID__: 'b',
          field: {__dataID__: 'c'},
        },
        c: {
          __dataID__: 'c',
        },
        unreachable: {
          __dataID__: 'unreachable',
        },
      };
      const {garbageCollector, storeData} = createGC(records);
      garbageCollector.register('a');
      garbageCollector.register('b');
      garbageCollector.register('c');
      garbageCollector.register('unreachable');

      garbageCollector.collectFromNode('a');
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual({
        unreachable: {__dataID__: 'unreachable'},
      });
    });

    it('skips referenced nodes', () => {
      const records = {
        a: {
          __dataID__: 'a',
          unreferenced: {__dataID__: 'unreferenced'},
          referenced: {__dataID__: 'referenced'},
        },
        unreferenced: {
          __dataID__: 'unreferenced',
        },
        referenced: {
          __dataID__: 'referenced',
        },
      };
      const {garbageCollector, storeData} = createGC(records);
      garbageCollector.register('a');
      garbageCollector.register('referenced');
      garbageCollector.register('unreferenced');
      garbageCollector.incrementReferenceCount('referenced');

      garbageCollector.collectFromNode('a');
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual({
        referenced: {__dataID__: 'referenced'},
      });
    });

    it('handles deleted/removed nodes', () => {
      const records = {
        a: {
          __dataID__: 'a',
          removed: {__dataID__: 'removed'},
          deleted: {__dataID__: 'deleted'},
          b: {__dataID__: 'b'},
        },
        deleted: null,
        b: {
          __dataID__: 'b',
        },
      };
      const {garbageCollector, storeData} = createGC(records);
      garbageCollector.register('a');
      garbageCollector.register('b');
      garbageCollector.register('deleted');

      garbageCollector.collectFromNode('a');
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual({});
    });

    it('collects connection edges and nodes', () => {
      const records = {
        unreachable: {
          __dataID__: 'unreachable',
        },
      };
      const {garbageCollector, storeData} = createGC(records);
      const payload = {
        viewer: {
          newsFeed: {
            edges: [
              {
                cursor: 'c1',
                node: {
                  id:'s1',
                  message:{
                    text:'s1',
                  },
                  __typename: 'Story',
                },
              },
            ],
            [PAGE_INFO]: {
              [HAS_NEXT_PAGE]: true,
              [HAS_PREV_PAGE]: false,
            },
          },
        },
      };
      const query = getNode(Relay.QL`
        query {
          viewer {
            newsFeed(first:"1") {
              edges {
                node {
                  message {
                    text
                  }
                }
              }
            }
          }
        }
      `);
      storeData.handleQueryPayload(
        query,
        transformRelayQueryPayload(query, payload)
      );
      const viewerID = storeData.getRecordStore().getDataID('viewer', null);
      garbageCollector.collectFromNode(viewerID);
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual({
        unreachable: {__dataID__: 'unreachable'},
      });
    });
  });

  describe('acquireHold()', () => {
    it('collects nodes if no holds are acquired', () => {
      // base case
      const records = {
        a: {__dataID__: 'a'},
      };
      const {garbageCollector, storeData} = createGC(records);
      garbageCollector.register('a');
      garbageCollector.collectFromNode('a');
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual({});
    });

    it('waits to collect until holds are released', () => {
      const records = {
        a: {__dataID__: 'a'},
      };
      const {garbageCollector, storeData} = createGC(records);
      const {release} = garbageCollector.acquireHold();
      garbageCollector.register('a');
      garbageCollector.collectFromNode('a');
      jest.runAllTimers();
      // not collected while hold is active
      expect(storeData.getNodeData()).toEqual(records);
      release();
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual({});
    });

    it('throws if a hold is released more than once', () => {
      const {garbageCollector} = createGC({});
      const {release} = garbageCollector.acquireHold();
      release();
      expect(() => release()).toFailInvariant(
        'RelayGarbageCollector: hold can only be released once.'
      );
    });

    it('skips collection if a hold is active', () => {
      const records = {
        a: {__dataID__: 'a'},
      };
      let run = null;
      const {garbageCollector, storeData} = createGC(records, _run => {
        run = _run;
      });
      garbageCollector.register('a');
      garbageCollector.collect();
      jest.runAllTimers();

      const {release} = garbageCollector.acquireHold();
      run();
      // not collected while hold is active
      expect(storeData.getNodeData()).toEqual(records);
      release();
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual(records);
      run();
      expect(storeData.getNodeData()).toEqual({});
    });
  });

  describe('scheduling', () => {
    it('does not call scheduler if there is nothing to collect', () => {
      const records = {
        a: {__dataID__: 'a'},
      };
      const scheduler = jest.fn();
      const {garbageCollector} = createGC(records, scheduler);
      garbageCollector.register('a');
      garbageCollector.incrementReferenceCount('a');
      garbageCollector.collectFromNode('a');
      expect(scheduler).not.toBeCalled();
    });

    it('does not call scheduler if no collections are enqueued', () => {
      const records = {
        a: {__dataID__: 'a'},
      };
      const scheduler = jest.fn();
      const {garbageCollector} = createGC(records, scheduler);
      garbageCollector.register('a');

      const {release} = garbageCollector.acquireHold();
      release();
      expect(scheduler).not.toBeCalled();
    });

    it('calls the injected scheduler and collects one record at a time', () => {
      const records = {
        a: {
          __dataID__: 'a',
          field: {__dataID__: 'b'},
        },
        b: {
          __dataID__: 'b',
          field: {__dataID__: 'c'},
        },
        c: {
          __dataID__: 'c',
          field: {__dataID__: 'd'},
        },
        d: {
          __dataID__: 'd',
        },
      };
      let run = null;
      const {garbageCollector, storeData} = createGC(records, _run => {
        run = _run;
      });
      garbageCollector.register('a');
      garbageCollector.register('b');
      garbageCollector.register('c');
      garbageCollector.register('d');

      garbageCollector.collectFromNode('a', 1);
      jest.runAllTimers();
      expect(storeData.getNodeData()).toEqual(records);
      expect(run()).toBe(true);
      expect(storeData.getNodeData()).toEqual({
        b: records.b,
        c: records.c,
        d: records.d,
      });
      expect(run()).toBe(true);
      expect(storeData.getNodeData()).toEqual({
        c: records.c,
        d: records.d,
      });
      expect(run()).toBe(true);
      expect(storeData.getNodeData()).toEqual({
        d: records.d,
      });
      expect(run()).toBe(false);
      expect(storeData.getNodeData()).toEqual({});
    });

    it('does not overlap collections', () => {
      const records = {
        a: {__dataID__: 'a'},
        b: {__dataID__: 'b'},
      };
      let run = null;
      const scheduler = jest.fn(_run => {
        run = _run;
      });
      const {garbageCollector} = createGC(records, scheduler);
      garbageCollector.register('a');
      garbageCollector.register('b');
      garbageCollector.collectFromNode('a');
      garbageCollector.collectFromNode('a');
      jest.runAllTimers();
      expect(scheduler.mock.calls.length).toBe(1);
      run();
      run(); // 'a' is enqueued twice
      scheduler.mockClear();
      garbageCollector.collectFromNode('b');
      jest.runAllTimers();
      jest.runAllTimers();
      expect(scheduler.mock.calls.length).toBe(1);
    });
  });

  describe('interaction with query tracking', () => {
    let garbageCollector;
    let records;
    let storeData;

    beforeEach(() => {
      records = {
        foo: {__dataID__: 'foo'},
      };
      ({garbageCollector, storeData} = createGC(records));
      garbageCollector.register('foo');
    });

    it('marks nodes as untracked when collected', () => {
      const tracker = storeData.getQueryTracker();
      tracker.untrackNodesForID = jest.fn();
      garbageCollector.collectFromNode('foo');
      jest.runAllTimers();
      expect(tracker.untrackNodesForID.mock.calls).toEqual([['foo']]);
    });

    it('behaves gracefully in the absence of a query tracker', () => {
      storeData.injectQueryTracker(null);
      expect(() => {
        garbageCollector.collectFromNode('foo');
        jest.runAllTimers();
      }).not.toThrow();
    });
  });
});