Bug 1833753 [wpt PR 40065] - Allow newly-added test to also pass when mutation events...
[gecko.git] / toolkit / content / preferencesBindings.js
blobb2e4070cf5217c9a037ae049e1f4be4ecb75b3d8
1 /* - This Source Code Form is subject to the terms of the Mozilla Public
2    - License, v. 2.0. If a copy of the MPL was not distributed with this file,
3    - You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 // We attach Preferences to the window object so other contexts (tests, JSMs)
8 // have access to it.
9 const Preferences = (window.Preferences = (function () {
10   const { EventEmitter } = ChromeUtils.importESModule(
11     "resource://gre/modules/EventEmitter.sys.mjs"
12   );
14   const lazy = {};
15   ChromeUtils.defineESModuleGetters(lazy, {
16     DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
17   });
19   function getElementsByAttribute(name, value) {
20     // If we needed to defend against arbitrary values, we would escape
21     // double quotes (") and escape characters (\) in them, i.e.:
22     //   ${value.replace(/["\\]/g, '\\$&')}
23     return value
24       ? document.querySelectorAll(`[${name}="${value}"]`)
25       : document.querySelectorAll(`[${name}]`);
26   }
28   const domContentLoadedPromise = new Promise(resolve => {
29     window.addEventListener("DOMContentLoaded", resolve, {
30       capture: true,
31       once: true,
32     });
33   });
35   const Preferences = {
36     _all: {},
38     _add(prefInfo) {
39       if (this._all[prefInfo.id]) {
40         throw new Error(`preference with id '${prefInfo.id}' already added`);
41       }
42       const pref = new Preference(prefInfo);
43       this._all[pref.id] = pref;
44       domContentLoadedPromise.then(() => {
45         if (!this.updateQueued) {
46           pref.updateElements();
47         }
48       });
49       return pref;
50     },
52     add(prefInfo) {
53       const pref = this._add(prefInfo);
54       return pref;
55     },
57     addAll(prefInfos) {
58       prefInfos.map(prefInfo => this._add(prefInfo));
59     },
61     get(id) {
62       return this._all[id] || null;
63     },
65     getAll() {
66       return Object.values(this._all);
67     },
69     defaultBranch: Services.prefs.getDefaultBranch(""),
71     get type() {
72       return document.documentElement.getAttribute("type") || "";
73     },
75     get instantApply() {
76       // The about:preferences page forces instantApply.
77       // TODO: Remove forceEnableInstantApply in favor of always applying in a
78       // parent and never applying in a child (bug 1775386).
79       if (this._instantApplyForceEnabled) {
80         return true;
81       }
83       // Dialogs of type="child" are never instantApply.
84       return this.type !== "child";
85     },
87     _instantApplyForceEnabled: false,
89     // Override the computed value of instantApply for this window.
90     forceEnableInstantApply() {
91       this._instantApplyForceEnabled = true;
92     },
94     observe(subject, topic, data) {
95       const pref = this._all[data];
96       if (pref) {
97         pref.value = pref.valueFromPreferences;
98       }
99     },
101     updateQueued: false,
103     queueUpdateOfAllElements() {
104       if (this.updateQueued) {
105         return;
106       }
108       this.updateQueued = true;
110       Services.tm.dispatchToMainThread(() => {
111         let startTime = performance.now();
113         const elements = getElementsByAttribute("preference");
114         for (const element of elements) {
115           const id = element.getAttribute("preference");
116           let preference = this.get(id);
117           if (!preference) {
118             console.error(`Missing preference for ID ${id}`);
119             continue;
120           }
122           preference.setElementValue(element);
123         }
125         ChromeUtils.addProfilerMarker(
126           "Preferences",
127           { startTime },
128           `updateAllElements: ${elements.length} preferences updated`
129         );
131         this.updateQueued = false;
132       });
133     },
135     onUnload() {
136       Services.prefs.removeObserver("", this);
137     },
139     QueryInterface: ChromeUtils.generateQI(["nsITimerCallback", "nsIObserver"]),
141     _deferredValueUpdateElements: new Set(),
143     writePreferences(aFlushToDisk) {
144       // Write all values to preferences.
145       if (this._deferredValueUpdateElements.size) {
146         this._finalizeDeferredElements();
147       }
149       const preferences = Preferences.getAll();
150       for (const preference of preferences) {
151         preference.batching = true;
152         preference.valueFromPreferences = preference.value;
153         preference.batching = false;
154       }
155       if (aFlushToDisk) {
156         Services.prefs.savePrefFile(null);
157       }
158     },
160     getPreferenceElement(aStartElement) {
161       let temp = aStartElement;
162       while (
163         temp &&
164         temp.nodeType == Node.ELEMENT_NODE &&
165         !temp.hasAttribute("preference")
166       ) {
167         temp = temp.parentNode;
168       }
169       return temp && temp.nodeType == Node.ELEMENT_NODE ? temp : aStartElement;
170     },
172     _deferredValueUpdate(aElement) {
173       delete aElement._deferredValueUpdateTask;
174       const prefID = aElement.getAttribute("preference");
175       const preference = Preferences.get(prefID);
176       const prefVal = preference.getElementValue(aElement);
177       preference.value = prefVal;
178       this._deferredValueUpdateElements.delete(aElement);
179     },
181     _finalizeDeferredElements() {
182       for (const el of this._deferredValueUpdateElements) {
183         if (el._deferredValueUpdateTask) {
184           el._deferredValueUpdateTask.finalize();
185         }
186       }
187     },
189     userChangedValue(aElement) {
190       const element = this.getPreferenceElement(aElement);
191       if (element.hasAttribute("preference")) {
192         if (element.getAttribute("delayprefsave") != "true") {
193           const preference = Preferences.get(
194             element.getAttribute("preference")
195           );
196           const prefVal = preference.getElementValue(element);
197           preference.value = prefVal;
198         } else {
199           if (!element._deferredValueUpdateTask) {
200             element._deferredValueUpdateTask = new lazy.DeferredTask(
201               this._deferredValueUpdate.bind(this, element),
202               1000
203             );
204             this._deferredValueUpdateElements.add(element);
205           } else {
206             // Each time the preference is changed, restart the delay.
207             element._deferredValueUpdateTask.disarm();
208           }
209           element._deferredValueUpdateTask.arm();
210         }
211       }
212     },
214     onCommand(event) {
215       // This "command" event handler tracks changes made to preferences by
216       // the user in this window.
217       if (event.sourceEvent) {
218         event = event.sourceEvent;
219       }
220       this.userChangedValue(event.target);
221     },
223     onChange(event) {
224       // This "change" event handler tracks changes made to preferences by
225       // the user in this window.
226       this.userChangedValue(event.target);
227     },
229     onInput(event) {
230       // This "input" event handler tracks changes made to preferences by
231       // the user in this window.
232       this.userChangedValue(event.target);
233     },
235     _fireEvent(aEventName, aTarget) {
236       try {
237         const event = new CustomEvent(aEventName, {
238           bubbles: true,
239           cancelable: true,
240         });
241         return aTarget.dispatchEvent(event);
242       } catch (e) {
243         console.error(e);
244       }
245       return false;
246     },
248     onDialogAccept(event) {
249       let dialog = document.querySelector("dialog");
250       if (!this._fireEvent("beforeaccept", dialog)) {
251         event.preventDefault();
252         return false;
253       }
254       this.writePreferences(true);
255       return true;
256     },
258     close(event) {
259       if (Preferences.instantApply) {
260         window.close();
261       }
262       event.stopPropagation();
263       event.preventDefault();
264     },
266     handleEvent(event) {
267       switch (event.type) {
268         case "toggle":
269         case "change":
270           return this.onChange(event);
271         case "command":
272           return this.onCommand(event);
273         case "dialogaccept":
274           return this.onDialogAccept(event);
275         case "input":
276           return this.onInput(event);
277         case "unload":
278           return this.onUnload(event);
279         default:
280           return undefined;
281       }
282     },
284     _syncFromPrefListeners: new WeakMap(),
285     _syncToPrefListeners: new WeakMap(),
287     addSyncFromPrefListener(aElement, callback) {
288       this._syncFromPrefListeners.set(aElement, callback);
289       if (this.updateQueued) {
290         return;
291       }
292       // Make sure elements are updated correctly with the listener attached.
293       let elementPref = aElement.getAttribute("preference");
294       if (elementPref) {
295         let pref = this.get(elementPref);
296         if (pref) {
297           pref.updateElements();
298         }
299       }
300     },
302     addSyncToPrefListener(aElement, callback) {
303       this._syncToPrefListeners.set(aElement, callback);
304       if (this.updateQueued) {
305         return;
306       }
307       // Make sure elements are updated correctly with the listener attached.
308       let elementPref = aElement.getAttribute("preference");
309       if (elementPref) {
310         let pref = this.get(elementPref);
311         if (pref) {
312           pref.updateElements();
313         }
314       }
315     },
317     removeSyncFromPrefListener(aElement) {
318       this._syncFromPrefListeners.delete(aElement);
319     },
321     removeSyncToPrefListener(aElement) {
322       this._syncToPrefListeners.delete(aElement);
323     },
324   };
326   Services.prefs.addObserver("", Preferences);
327   window.addEventListener("toggle", Preferences);
328   window.addEventListener("change", Preferences);
329   window.addEventListener("command", Preferences);
330   window.addEventListener("dialogaccept", Preferences);
331   window.addEventListener("input", Preferences);
332   window.addEventListener("select", Preferences);
333   window.addEventListener("unload", Preferences, { once: true });
335   class Preference extends EventEmitter {
336     constructor({ id, type, inverted }) {
337       super();
338       this.on("change", this.onChange.bind(this));
340       this._value = null;
341       this.readonly = false;
342       this._useDefault = false;
343       this.batching = false;
345       this.id = id;
346       this.type = type;
347       this.inverted = !!inverted;
349       // In non-instant apply mode, we must try and use the last saved state
350       // from any previous opens of a child dialog instead of the value from
351       // preferences, to pick up any edits a user may have made.
353       if (
354         Preferences.type == "child" &&
355         window.opener &&
356         window.opener.Preferences &&
357         window.opener.document.nodePrincipal.isSystemPrincipal
358       ) {
359         // Try to find the preference in the parent window.
360         const preference = window.opener.Preferences.get(this.id);
362         // Don't use the value setter here, we don't want updateElements to be
363         // prematurely fired.
364         this._value = preference ? preference.value : this.valueFromPreferences;
365       } else {
366         this._value = this.valueFromPreferences;
367       }
368     }
370     reset() {
371       // defer reset until preference update
372       this.value = undefined;
373     }
375     _reportUnknownType() {
376       const msg = `Preference with id=${this.id} has unknown type ${this.type}.`;
377       Services.console.logStringMessage(msg);
378     }
380     setElementValue(aElement) {
381       if (this.locked) {
382         aElement.disabled = true;
383       }
385       if (!this.isElementEditable(aElement)) {
386         return;
387       }
389       let rv = undefined;
391       if (Preferences._syncFromPrefListeners.has(aElement)) {
392         rv = Preferences._syncFromPrefListeners.get(aElement)(aElement);
393       }
394       let val = rv;
395       if (val === undefined) {
396         val = Preferences.instantApply ? this.valueFromPreferences : this.value;
397       }
398       // if the preference is marked for reset, show default value in UI
399       if (val === undefined) {
400         val = this.defaultValue;
401       }
403       /**
404        * Initialize a UI element property with a value. Handles the case
405        * where an element has not yet had a XBL binding attached for it and
406        * the property setter does not yet exist by setting the same attribute
407        * on the XUL element using DOM apis and assuming the element's
408        * constructor or property getters appropriately handle this state.
409        */
410       function setValue(element, attribute, value) {
411         if (attribute in element) {
412           element[attribute] = value;
413         } else if (attribute === "checked" || attribute === "pressed") {
414           // The "checked" attribute can't simply be set to the specified value;
415           // it has to be set if the value is true and removed if the value
416           // is false in order to be interpreted correctly by the element.
417           if (value) {
418             // In theory we can set it to anything; however xbl implementation
419             // of `checkbox` only works with "true".
420             element.setAttribute(attribute, "true");
421           } else {
422             element.removeAttribute(attribute);
423           }
424         } else {
425           element.setAttribute(attribute, value);
426         }
427       }
428       if (
429         aElement.localName == "checkbox" ||
430         (aElement.localName == "input" && aElement.type == "checkbox")
431       ) {
432         setValue(aElement, "checked", val);
433       } else if (aElement.localName == "moz-toggle") {
434         setValue(aElement, "pressed", val);
435       } else {
436         setValue(aElement, "value", val);
437       }
438     }
440     getElementValue(aElement) {
441       if (Preferences._syncToPrefListeners.has(aElement)) {
442         try {
443           const rv = Preferences._syncToPrefListeners.get(aElement)(aElement);
444           if (rv !== undefined) {
445             return rv;
446           }
447         } catch (e) {
448           console.error(e);
449         }
450       }
452       /**
453        * Read the value of an attribute from an element, assuming the
454        * attribute is a property on the element's node API. If the property
455        * is not present in the API, then assume its value is contained in
456        * an attribute, as is the case before a binding has been attached.
457        */
458       function getValue(element, attribute) {
459         if (attribute in element) {
460           return element[attribute];
461         }
462         return element.getAttribute(attribute);
463       }
464       let value;
465       if (
466         aElement.localName == "checkbox" ||
467         (aElement.localName == "input" && aElement.type == "checkbox")
468       ) {
469         value = getValue(aElement, "checked");
470       } else if (aElement.localName == "moz-toggle") {
471         value = getValue(aElement, "pressed");
472       } else {
473         value = getValue(aElement, "value");
474       }
476       switch (this.type) {
477         case "int":
478           return parseInt(value, 10) || 0;
479         case "bool":
480           return typeof value == "boolean" ? value : value == "true";
481       }
482       return value;
483     }
485     isElementEditable(aElement) {
486       switch (aElement.localName) {
487         case "checkbox":
488         case "input":
489         case "radiogroup":
490         case "textarea":
491         case "menulist":
492         case "moz-toggle":
493           return true;
494       }
495       return false;
496     }
498     updateElements() {
499       let startTime = performance.now();
501       if (!this.id) {
502         return;
503       }
505       const elements = getElementsByAttribute("preference", this.id);
506       for (const element of elements) {
507         this.setElementValue(element);
508       }
510       ChromeUtils.addProfilerMarker(
511         "Preferences",
512         { startTime, captureStack: true },
513         `updateElements for ${this.id}`
514       );
515     }
517     onChange() {
518       this.updateElements();
519     }
521     get value() {
522       return this._value;
523     }
525     set value(val) {
526       if (this.value !== val) {
527         this._value = val;
528         if (Preferences.instantApply) {
529           this.valueFromPreferences = val;
530         }
531         this.emit("change");
532       }
533     }
535     get locked() {
536       return Services.prefs.prefIsLocked(this.id);
537     }
539     updateControlDisabledState(val) {
540       if (!this.id) {
541         return;
542       }
544       val = val || this.locked;
546       const elements = getElementsByAttribute("preference", this.id);
547       for (const element of elements) {
548         element.disabled = val;
550         const labels = getElementsByAttribute("control", element.id);
551         for (const label of labels) {
552           label.disabled = val;
553         }
554       }
555     }
557     get hasUserValue() {
558       return (
559         Services.prefs.prefHasUserValue(this.id) && this.value !== undefined
560       );
561     }
563     get defaultValue() {
564       this._useDefault = true;
565       const val = this.valueFromPreferences;
566       this._useDefault = false;
567       return val;
568     }
570     get _branch() {
571       return this._useDefault ? Preferences.defaultBranch : Services.prefs;
572     }
574     get valueFromPreferences() {
575       try {
576         // Force a resync of value with preferences.
577         switch (this.type) {
578           case "int":
579             return this._branch.getIntPref(this.id);
580           case "bool": {
581             const val = this._branch.getBoolPref(this.id);
582             return this.inverted ? !val : val;
583           }
584           case "wstring":
585             return this._branch.getComplexValue(
586               this.id,
587               Ci.nsIPrefLocalizedString
588             ).data;
589           case "string":
590           case "unichar":
591             return this._branch.getStringPref(this.id);
592           case "fontname": {
593             const family = this._branch.getStringPref(this.id);
594             const fontEnumerator = Cc[
595               "@mozilla.org/gfx/fontenumerator;1"
596             ].createInstance(Ci.nsIFontEnumerator);
597             return fontEnumerator.getStandardFamilyName(family);
598           }
599           case "file": {
600             const f = this._branch.getComplexValue(this.id, Ci.nsIFile);
601             return f;
602           }
603           default:
604             this._reportUnknownType();
605         }
606       } catch (e) {}
607       return null;
608     }
610     set valueFromPreferences(val) {
611       // Exit early if nothing to do.
612       if (this.readonly || this.valueFromPreferences == val) {
613         return;
614       }
616       // The special value undefined means 'reset preference to default'.
617       if (val === undefined) {
618         Services.prefs.clearUserPref(this.id);
619         return;
620       }
622       // Force a resync of preferences with value.
623       switch (this.type) {
624         case "int":
625           Services.prefs.setIntPref(this.id, val);
626           break;
627         case "bool":
628           Services.prefs.setBoolPref(this.id, this.inverted ? !val : val);
629           break;
630         case "wstring": {
631           const pls = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(
632             Ci.nsIPrefLocalizedString
633           );
634           pls.data = val;
635           Services.prefs.setComplexValue(
636             this.id,
637             Ci.nsIPrefLocalizedString,
638             pls
639           );
640           break;
641         }
642         case "string":
643         case "unichar":
644         case "fontname":
645           Services.prefs.setStringPref(this.id, val);
646           break;
647         case "file": {
648           let lf;
649           if (typeof val == "string") {
650             lf = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
651             lf.persistentDescriptor = val;
652             if (!lf.exists()) {
653               lf.initWithPath(val);
654             }
655           } else {
656             lf = val.QueryInterface(Ci.nsIFile);
657           }
658           Services.prefs.setComplexValue(this.id, Ci.nsIFile, lf);
659           break;
660         }
661         default:
662           this._reportUnknownType();
663       }
664       if (!this.batching) {
665         Services.prefs.savePrefFile(null);
666       }
667     }
668   }
670   return Preferences;
671 })());