summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/changes/reducers/changes.js
blob: 82a5a92ed7761437029860e0a2dde1ae845dbb3b (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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
/* 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/. */

"use strict";

const {
  RESET_CHANGES,
  TRACK_CHANGE,
} = require("resource://devtools/client/inspector/changes/actions/index.js");

/**
 * Return a deep clone of the given state object.
 *
 * @param {Object} state
 * @return {Object}
 */
function cloneState(state = {}) {
  return Object.entries(state).reduce((sources, [sourceId, source]) => {
    sources[sourceId] = {
      ...source,
      rules: Object.entries(source.rules).reduce((rules, [ruleId, rule]) => {
        rules[ruleId] = {
          ...rule,
          selectors: rule.selectors.slice(0),
          children: rule.children.slice(0),
          add: rule.add.slice(0),
          remove: rule.remove.slice(0),
        };

        return rules;
      }, {}),
    };

    return sources;
  }, {});
}

/**
 * Given information about a CSS rule and its ancestor rules (@media, @supports, etc),
 * create entries in the given rules collection for each rule and assign parent/child
 * dependencies.
 *
 * @param {Object} ruleData
 *        Information about a CSS rule:
 *        {
 *          id:        {String}
 *                     Unique rule id.
 *          selectors: {Array}
 *                     Array of CSS selector text
 *          ancestors: {Array}
 *                     Flattened CSS rule tree of the rule's ancestors with the root rule
 *                     at the beginning of the array and the leaf rule at the end.
 *          ruleIndex: {Array}
 *                     Indexes of each ancestor rule within its parent rule.
 *        }
 *
 * @param {Object} rules
 *        Collection of rules to be mutated.
 *        This is a reference to the corresponding `rules` object from the state.
 *
 * @return {Object}
 *         Entry for the CSS rule created the given collection of rules.
 */
function createRule(ruleData, rules) {
  // Append the rule data to the flattened CSS rule tree with its ancestors.
  const ruleAncestry = [...ruleData.ancestors, { ...ruleData }];

  return (
    ruleAncestry
      .map((rule, index) => {
        // Ensure each rule has ancestors excluding itself (expand the flattened rule tree).
        rule.ancestors = ruleAncestry.slice(0, index);
        // Ensure each rule has a selector text.
        // For the purpose of displaying in the UI, we treat at-rules as selectors.
        if (!rule.selectors || !rule.selectors.length) {
          // Display the @type label if there's one
          let selector = rule.typeName ? rule.typeName + " " : "";
          selector +=
            rule.conditionText ||
            rule.name ||
            rule.keyText ||
            rule.selectorText;

          rule.selectors = [selector];
        }

        return rule.id;
      })
      // Then, create new entries in the rules collection and assign dependencies.
      .map((ruleId, index, array) => {
        const { selectors } = ruleAncestry[index];
        const prevRuleId = array[index - 1];
        const nextRuleId = array[index + 1];

        // Create an entry for this ruleId if one does not exist.
        if (!rules[ruleId]) {
          rules[ruleId] = {
            ruleId,
            isNew: false,
            selectors,
            add: [],
            remove: [],
            children: [],
            parent: null,
          };
        }

        // The next ruleId is lower in the rule tree, therefore it's a child of this rule.
        if (nextRuleId && !rules[ruleId].children.includes(nextRuleId)) {
          rules[ruleId].children.push(nextRuleId);
        }

        // The previous ruleId is higher in the rule tree, therefore it's the parent.
        if (prevRuleId) {
          rules[ruleId].parent = prevRuleId;
        }

        return rules[ruleId];
      })
      // Finally, return the last rule in the array which is the rule we set out to create.
      .pop()
  );
}

function removeRule(ruleId, rules) {
  const rule = rules[ruleId];

  // First, remove this rule's id from its parent's list of children
  if (rule.parent && rules[rule.parent]) {
    rules[rule.parent].children = rules[rule.parent].children.filter(
      childRuleId => {
        return childRuleId !== ruleId;
      }
    );

    // Remove the parent rule if it has no children left.
    if (!rules[rule.parent].children.length) {
      removeRule(rule.parent, rules);
    }
  }

  delete rules[ruleId];
}

/**
 * Aggregated changes grouped by sources (stylesheet/element), which contain rules,
 * which contain collections of added and removed CSS declarations.
 *
 * Structure:
 *    <sourceId>: {
 *      type: // {String} One of: "stylesheet", "inline" or "element"
 *      href: // {String|null} Stylesheet or document URL; null for inline stylesheets
 *      rules: {
 *        <ruleId>: {
 *          ruleId:      // {String} <ruleId> of this rule
 *          isNew:       // {Boolean} Whether the tracked rule was created at runtime,
 *                       //           meaning it didn't originally exist in the source.
 *          selectors:   // {Array} of CSS selectors or CSS at-rule text.
 *                       //         The array has just one item if the selector is never
 *                       //         changed. When the rule's selector is changed, the new
 *                       //         selector is pushed onto this array.
 *          children: [] // {Array} of <ruleId> for child rules of this rule
 *          parent:      // {String} <ruleId> of the parent rule
 *          add: [       // {Array} of objects with CSS declarations
 *            {
 *              property:    // {String} CSS property name
 *              value:       // {String} CSS property value
 *              index:       // {Number} Position of the declaration within its CSS rule
 *            }
 *            ... // more declarations
 *          ],
 *          remove: []   // {Array} of objects with CSS declarations
 *        }
 *        ... // more rules
 *      }
 *    }
 *    ... // more sources
 */
const INITIAL_STATE = {};

const reducers = {
  /**
   * CSS changes are collected on the server by the ChangesActor which dispatches them to
   * the client as atomic operations: a rule/declaration updated, added or removed.
   *
   * By design, the ChangesActor has no big-picture context of all the collected changes.
   * It only holds the stack of atomic changes. This makes it roboust for many use cases:
   * building a diff-view, supporting undo/redo, offline persistence, etc. Consumers,
   * like the Changes panel, get to massage the data for their particular purposes.
   *
   * Here in the reducer, we aggregate incoming changes to build a human-readable diff
   * shown in the Changes panel.
   * - added / removed declarations are grouped by CSS rule. Rules are grouped by their
   *   parent rules (@media, @supports, @keyframes, etc.); Rules belong to sources
   *   (stylesheets, inline styles)
   * - declarations have an index corresponding to their position in the CSS rule. This
   *   allows tracking of multiple declarations with the same property name.
   * - repeated changes a declaration will show only the original removal and the latest
   *   addition;
   * - when a declaration is removed, we update the indices of other tracked declarations
   *   in the same rule which may have changed position in the rule as a result;
   * - changes which cancel each other out (i.e. return to original) are both removed
   *   from the store;
   * - when changes cancel each other out leaving the rule unchanged, the rule is removed
   *   from the store. Its parent rule is removed as well if it too ends up unchanged.
   */
  // eslint-disable-next-line complexity
  [TRACK_CHANGE](state, { change }) {
    const defaults = {
      selector: null,
      source: {},
      ancestors: [],
      add: [],
      remove: [],
    };

    change = { ...defaults, ...change };
    state = cloneState(state);

    const { selector, ancestors, ruleIndex } = change;
    const sourceId = change.source.id;
    const ruleId = change.id;

    // Copy or create object identifying the source (styelsheet/element) for this change.
    const source = Object.assign({}, state[sourceId], change.source);
    // Copy or create collection of all rules ever changed in this source.
    const rules = Object.assign({}, source.rules);
    // Reference or create object identifying the rule for this change.
    const rule = rules[ruleId]
      ? rules[ruleId]
      : createRule(
          { id: change.id, selectors: [selector], ancestors, ruleIndex },
          rules
        );

    // Mark the rule if it was created at runtime as a result of an "Add Rule" action.
    if (change.type === "rule-add") {
      rule.isNew = true;
    }

    // If the first selector tracked for this rule is identical to the incoming selector,
    // reduce the selectors array to a single one. This handles the case for renaming a
    // selector back to its original name. It has no side effects for other changes which
    // preserve the selector.
    // If the rule was created at runtime, always reduce the selectors array to one item.
    // Changes to the new rule's selector always overwrite the original selector.
    // If the selectors are different, push the incoming one to the end of the array to
    // signify that the rule has changed selector. The last item is the current selector.
    if (rule.selectors[0] === selector || rule.isNew) {
      rule.selectors = [selector];
    } else {
      rule.selectors.push(selector);
    }

    if (change.remove?.length) {
      for (const decl of change.remove) {
        // Find the position of any added declaration which matches the incoming
        // declaration to be removed.
        const addIndex = rule.add.findIndex(addDecl => {
          return (
            addDecl.index === decl.index &&
            addDecl.property === decl.property &&
            addDecl.value === decl.value
          );
        });

        // Find the position of any removed declaration which matches the incoming
        // declaration to be removed. It's possible to get duplicate remove operations
        // when, for example, disabling a declaration then deleting it.
        const removeIndex = rule.remove.findIndex(removeDecl => {
          return (
            removeDecl.index === decl.index &&
            removeDecl.property === decl.property &&
            removeDecl.value === decl.value
          );
        });

        // Track the remove operation only if the property was not previously introduced
        // by an add operation. This ensures repeated changes of the same property
        // register as a single remove operation of its original value. Avoid tracking the
        // remove declaration if already tracked (happens on disable followed by delete).
        if (addIndex < 0 && removeIndex < 0) {
          rule.remove.push(decl);
        }

        // Delete any previous add operation which would be canceled out by this remove.
        if (rule.add[addIndex]) {
          rule.add.splice(addIndex, 1);
        }

        // Update the indexes of previously tracked declarations which follow this removed
        // one so future tracking continues to point to the right declarations.
        if (change.type === "declaration-remove") {
          rule.add = rule.add.map(addDecl => {
            if (addDecl.index > decl.index) {
              addDecl.index--;
            }

            return addDecl;
          });

          rule.remove = rule.remove.map(removeDecl => {
            if (removeDecl.index > decl.index) {
              removeDecl.index--;
            }

            return removeDecl;
          });
        }
      }
    }

    if (change.add?.length) {
      for (const decl of change.add) {
        // Find the position of any removed declaration which matches the incoming
        // declaration to be added.
        const removeIndex = rule.remove.findIndex(removeDecl => {
          return (
            removeDecl.index === decl.index &&
            removeDecl.value === decl.value &&
            removeDecl.property === decl.property
          );
        });

        // Find the position of any added declaration which matches the incoming
        // declaration to be added in case we need to replace it.
        const addIndex = rule.add.findIndex(addDecl => {
          return (
            addDecl.index === decl.index && addDecl.property === decl.property
          );
        });

        if (rule.remove[removeIndex]) {
          // Delete any previous remove operation which would be canceled out by this add.
          rule.remove.splice(removeIndex, 1);
        } else if (rule.add[addIndex]) {
          // Replace previous add operation for declaration at this index.
          rule.add.splice(addIndex, 1, decl);
        } else {
          // Track new add operation.
          rule.add.push(decl);
        }
      }
    }

    // Remove the rule if none of its declarations or selector have changed,
    // but skip cleanup if the selector is in process of being renamed (there are two
    // changes happening in quick succession: selector-remove + selector-add) or if the
    // rule was created at runtime (allow empty new rules to persist).
    if (
      !rule.add.length &&
      !rule.remove.length &&
      rule.selectors.length === 1 &&
      !change.type.startsWith("selector-") &&
      !rule.isNew
    ) {
      removeRule(ruleId, rules);
      source.rules = { ...rules };
    } else {
      source.rules = { ...rules, [ruleId]: rule };
    }

    // Remove information about the source if none of its rules changed.
    if (!Object.keys(source.rules).length) {
      delete state[sourceId];
    } else {
      state[sourceId] = source;
    }

    return state;
  },

  [RESET_CHANGES]() {
    return INITIAL_STATE;
  },
};

module.exports = function (state = INITIAL_STATE, action) {
  const reducer = reducers[action.type];
  if (!reducer) {
    return state;
  }
  return reducer(state, action);
};