summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/changes/reducers/changes.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/changes/reducers/changes.js')
-rw-r--r--devtools/client/inspector/changes/reducers/changes.js381
1 files changed, 381 insertions, 0 deletions
diff --git a/devtools/client/inspector/changes/reducers/changes.js b/devtools/client/inspector/changes/reducers/changes.js
new file mode 100644
index 0000000000..206983f512
--- /dev/null
+++ b/devtools/client/inspector/changes/reducers/changes.js
@@ -0,0 +1,381 @@
+/* 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) {
+ rule.selectors = [
+ `${rule.typeName} ${
+ rule.conditionText || rule.name || rule.keyText
+ }`,
+ ];
+ }
+
+ 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](state) {
+ return INITIAL_STATE;
+ },
+};
+
+module.exports = function (state = INITIAL_STATE, action) {
+ const reducer = reducers[action.type];
+ if (!reducer) {
+ return state;
+ }
+ return reducer(state, action);
+};