Bug 1499049 - (Part 3) Add new reducer logic for tracking CSS changes to nested rules; r=pbro
authorRazvan Caliman <rcaliman@mozilla.com>
Thu, 25 Oct 2018 16:20:00 +0000
changeset 443118 90491f2fdf3db959f918b6419f013c2f887f17a7
parent 443117 1d612a71f940facc02938a5666e68f56902e8022
child 443119 578d839f3c6c472b43b0fc9b4753549ebdb9ccb3
push id71803
push userrcaliman@mozilla.com
push dateFri, 26 Oct 2018 11:29:10 +0000
treeherderautoland@d0c4e1db970e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1499049
milestone65.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1499049 - (Part 3) Add new reducer logic for tracking CSS changes to nested rules; r=pbro Depends on D8719 - Add methods to generate unique identifiers for stylesheets and CSS rules changed within those stylesheets. These are used as IDs in the Redux store; - Add logic to generate entries in the store for each one of the rule's ancestors and assign parent/child dependencies. This single-level structure for all rules in a source helps with quickly identifying a rule on subsequent changes independent of its rule tree (it avoids needless tree traversal). The parent/child references help with rendering of the nested rule structure in the Changes panel; - Deep clone Redux store state before aggregating tracked changes (no more mutations of previous state). Differential Revision: https://phabricator.services.mozilla.com/D8720
devtools/client/inspector/changes/actions/changes.js
devtools/client/inspector/changes/moz.build
devtools/client/inspector/changes/reducers/changes.js
devtools/client/inspector/changes/utils/changes-utils.js
devtools/client/inspector/changes/utils/moz.build
--- a/devtools/client/inspector/changes/actions/changes.js
+++ b/devtools/client/inspector/changes/actions/changes.js
@@ -12,16 +12,16 @@ const {
 module.exports = {
 
   resetChanges() {
     return {
       type: RESET_CHANGES,
     };
   },
 
-  trackChange(data) {
+  trackChange(change) {
     return {
       type: TRACK_CHANGE,
-      data,
+      change,
     };
   },
 
 };
--- a/devtools/client/inspector/changes/moz.build
+++ b/devtools/client/inspector/changes/moz.build
@@ -3,14 +3,15 @@
 # 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/.
 
 DIRS += [
     'actions',
     'components',
     'reducers',
+    'utils',
 ]
 
 DevToolsModules(
     'ChangesManager.js',
     'ChangesView.js',
 )
--- a/devtools/client/inspector/changes/reducers/changes.js
+++ b/devtools/client/inspector/changes/reducers/changes.js
@@ -1,24 +1,190 @@
 /* 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 { getSourceHash, getRuleHash } = require("../utils/changes-utils");
+
 const {
   RESET_CHANGES,
   TRACK_CHANGE,
 } = require("../actions/index");
 
+/**
+ * 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,
+          children: rule.children.slice(0),
+          add: { ...rule.add },
+          remove: { ...rule.remove },
+        };
+
+        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:
+ *        {
+ *          selector:  {String}
+ *                     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
+    // First, generate a unique identifier for each rule.
+    .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.selector) {
+        rule.selector =
+          `${rule.typeName} ${(rule.conditionText || rule.name || rule.keyText)}`;
+      }
+
+      return getRuleHash(rule);
+    })
+    // Then, create new entries in the rules collection and assign dependencies.
+    .map((ruleId, index, array) => {
+      const { selector } = ruleAncestry[index];
+      const prevRuleId = array[index - 1];
+      const nextRuleId = array[index + 1];
+
+      // Copy or create an entry for this rule.
+      rules[ruleId] = Object.assign({}, { selector, children: [] }, rules[ruleId]);
+
+      // 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();
+}
+
+/**
+ * Aggregated changes grouped by sources (stylesheet/element), which contain rules,
+ * which contain collections of added and removed CSS declarations.
+ *
+ * Structure:
+ *    <sourceId>: {
+ *      type: // "stylesheet" or "element"
+ *      href: // Stylesheet or document URL
+ *      rules: {
+ *        <ruleId>: {
+ *          selector: "" // String CSS selector or CSS at-rule text
+ *          children: [] // Array of <ruleId> for child rules of this rule.
+ *          parent:      // <ruleId> of the parent rule
+ *          add: {
+ *            <property>: <value> // CSS declaration
+ *            ...
+ *          },
+ *          remove: {
+ *            <property>: <value> // CSS declaration
+ *           ...
+ *          }
+ *        }
+ *        ... // more rules
+ *      }
+ *    }
+ *    ... // more sources
+ */
 const INITIAL_STATE = {};
 
 const reducers = {
 
-  [TRACK_CHANGE](state, { data }) {
+  [TRACK_CHANGE](state, { change }) {
+    const defaults = {
+      selector: null,
+      source: {},
+      ancestors: [],
+      add: {},
+      remove: {},
+    };
+
+    change = { ...defaults, ...change };
+    state = cloneState(state);
+
+    const { type, href, index } = change.source;
+    const { selector, ancestors, ruleIndex } = change;
+    const sourceId = getSourceHash(change.source);
+    const ruleId = getRuleHash({ selector, ancestors, ruleIndex });
+
+    // Copy or create object identifying the source (styelsheet/element) for this change.
+    const source = Object.assign({}, state[sourceId], { type, href, index });
+    // Copy or create collection of all rules ever changed in this source.
+    const rules = Object.assign({}, source.rules);
+    // Refrence or create object identifying the rule for this change.
+    let rule = rules[ruleId];
+    if (!rule) {
+      rule = createRule({ selector, ancestors, ruleIndex }, rules);
+    }
+    // Copy or create collection of all CSS declarations ever added to this rule.
+    const add = Object.assign({}, rule.add);
+    // Copy or create collection of all CSS declarations ever removed from this rule.
+    const remove = Object.assign({}, rule.remove);
+
+    // 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.
+    if (change.remove && change.remove.property && !add[change.remove.property]) {
+      remove[change.remove.property] = change.remove.value;
+    }
+
+    if (change.add && change.add.property) {
+      add[change.add.property] = change.add.value;
+    }
+
+    source.rules = { ...rules, [ruleId]: { ...rule, add, remove } };
+    state[sourceId] = source;
+
     return state;
   },
 
   [RESET_CHANGES](state) {
     return INITIAL_STATE;
   },
 
 };
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/utils/changes-utils.js
@@ -0,0 +1,58 @@
+/* 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";
+
+/**
+* Generate a hash that uniquely identifies a stylesheet or element style attribute.
+*
+* @param {Object} source
+*        Information about a stylesheet or element style attribute:
+*        {
+*          type:  {String}
+*                 One of "stylesheet" or "element".
+*          index: {Number|String}
+*                 Position of the styleshet in the list of stylesheets in the document.
+*                 If `type` is "element", `index` is the generated selector which
+*                 uniquely identifies the element in the document.
+*          href:  {String|null}
+*                 URL of the stylesheet or of the document when `type` is "element".
+*                 If the stylesheet is inline, `href` is null.
+*        }
+* @return {String}
+*/
+function getSourceHash(source) {
+  const { type, index, href = "inline" } = source;
+
+  return `${type}${index}${href}`;
+}
+
+/**
+* Generate a hash that uniquely identifies a CSS rule.
+*
+* @param {Object} ruleData
+*        Information about a CSS rule:
+*        {
+*          selector:  {String}
+*                     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.
+*        }
+* @return {String}
+*/
+function getRuleHash(ruleData) {
+  const { selector = "", ancestors = [], ruleIndex } = ruleData;
+  const atRules = ancestors.reduce((acc, rule) => {
+    acc += `${rule.typeName} ${(rule.conditionText || rule.name || rule.keyText)}`;
+    return acc;
+  }, "");
+
+  return `${atRules}${selector}${ruleIndex}`;
+}
+
+module.exports.getSourceHash = getSourceHash;
+module.exports.getRuleHash = getRuleHash;
copy from devtools/client/inspector/changes/moz.build
copy to devtools/client/inspector/changes/utils/moz.build
--- a/devtools/client/inspector/changes/moz.build
+++ b/devtools/client/inspector/changes/utils/moz.build
@@ -1,16 +1,9 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
-DIRS += [
-    'actions',
-    'components',
-    'reducers',
-]
-
 DevToolsModules(
-    'ChangesManager.js',
-    'ChangesView.js',
+    'changes-utils.js',
 )