summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/lib/LinksCache.sys.mjs
blob: 0dfb89e74ea18d19a70ad4dde9dd226dc31c29ab (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// This should be slightly less than SYSTEM_TICK_INTERVAL as timer
// comparisons are too exact while the async/await functionality will make the
// last recorded time a little bit later. This causes the comparasion to skip
// updates.
// It should be 10% less than SYSTEM_TICK to update at least once every 5 mins.
// https://github.com/mozilla/activity-stream/pull/3695#discussion_r144678214
const EXPIRATION_TIME = 4.5 * 60 * 1000; // 4.5 minutes

/**
 * Cache link results from a provided object property and refresh after some
 * amount of time has passed. Allows for migrating data from previously cached
 * links to the new links with the same url.
 */
export class LinksCache {
  /**
   * Create a links cache for a given object property.
   *
   * @param {object} linkObject Object containing the link property
   * @param {string} linkProperty Name of property on object to access
   * @param {array} properties Optional properties list to migrate to new links.
   * @param {function} shouldRefresh Optional callback receiving the old and new
   *                                 options to refresh even when not expired.
   */
  constructor(
    linkObject,
    linkProperty,
    properties = [],
    shouldRefresh = () => {}
  ) {
    this.clear();

    // Allow getting links from both methods and array properties
    this.linkGetter = options => {
      const ret = linkObject[linkProperty];
      return typeof ret === "function" ? ret.call(linkObject, options) : ret;
    };

    // Always migrate the shared cache data in addition to any custom properties
    this.migrateProperties = ["__sharedCache", ...properties];
    this.shouldRefresh = shouldRefresh;
  }

  /**
   * Clear the cached data.
   */
  clear() {
    this.cache = Promise.resolve([]);
    this.lastOptions = {};
    this.expire();
  }

  /**
   * Force the next request to update the cache.
   */
  expire() {
    delete this.lastUpdate;
  }

  /**
   * Request data and update the cache if necessary.
   *
   * @param {object} options Optional data to pass to the underlying method.
   * @returns {promise(array)} Links array with objects that can be modified.
   */
  async request(options = {}) {
    // Update the cache if the data has been expired
    const now = Date.now();
    if (
      this.lastUpdate === undefined ||
      now > this.lastUpdate + EXPIRATION_TIME ||
      // Allow custom rules around refreshing based on options
      this.shouldRefresh(this.lastOptions, options)
    ) {
      // Update request state early so concurrent requests can refer to it
      this.lastOptions = options;
      this.lastUpdate = now;

      // Save a promise before awaits, so other requests wait for correct data
      // eslint-disable-next-line no-async-promise-executor
      this.cache = new Promise(async (resolve, reject) => {
        try {
          // Allow fast lookup of old links by url that might need to migrate
          const toMigrate = new Map();
          for (const oldLink of await this.cache) {
            if (oldLink) {
              toMigrate.set(oldLink.url, oldLink);
            }
          }

          // Update the cache with migrated links without modifying source objects
          resolve(
            (await this.linkGetter(options)).map(link => {
              // Keep original array hole positions
              if (!link) {
                return link;
              }

              // Migrate data to the new link copy if we have an old link
              const newLink = Object.assign({}, link);
              const oldLink = toMigrate.get(newLink.url);
              if (oldLink) {
                for (const property of this.migrateProperties) {
                  const oldValue = oldLink[property];
                  if (oldValue !== undefined) {
                    newLink[property] = oldValue;
                  }
                }
              } else {
                // Share data among link copies and new links from future requests
                newLink.__sharedCache = {};
              }
              // Provide a helper to update the cached link
              newLink.__sharedCache.updateLink = (property, value) => {
                newLink[property] = value;
              };

              return newLink;
            })
          );
        } catch (error) {
          reject(error);
        }
      });
    }

    // Provide a shallow copy of the cached link objects for callers to modify
    return (await this.cache).map(link => link && Object.assign({}, link));
  }
}