Guidelines for injected features development.
Features extend ContentFeature (which itself extends ConfigFeature). Use ContentFeature for features that need messaging, logging, and DOM interaction. Implement lifecycle methods:
import ContentFeature from '../content-feature.js';
export default class MyFeature extends ContentFeature {
init() {
// Main initialization - feature is enabled for this site
if (this.getFeatureSettingEnabled('someSetting')) {
this.applySomeFix();
}
}
load() {
// Early load - before remote config (use sparingly)
}
update(data) {
// Receive updates from browser
}
}
Use getFeatureSetting() and getFeatureSettingEnabled() to read config:
// Boolean check with default
if (this.getFeatureSettingEnabled('settingName')) { ... }
if (this.getFeatureSettingEnabled('settingName', 'disabled')) { ... } // default disabled
// Get setting value (returns typed object from privacy-configuration schema)
const settings = this.getFeatureSetting('settingName');
Feature state values: "enabled" or "disabled". Features default to disabled unless explicitly enabled.
Types are generated from @duckduckgo/privacy-configuration/schema/features/<name>.json.
Use conditionalChanges to apply JSON Patch operations based on runtime conditions. Conditions are evaluated in src/config-feature.js (see ConditionBlock typedef and _matchConditionalBlock).
Supported conditions:
| Condition | Description | Example |
|---|---|---|
domain |
Match hostname | "domain": "example.com" |
urlPattern |
Match URL (URLPattern API) | "urlPattern": "https://*.example.com/*" |
experiment |
Match A/B test cohort | "experiment": { "experimentName": "test", "cohort": "treatment" } |
context |
Match frame type | "context": { "frame": true } or "context": { "top": true } |
minSupportedVersion |
Minimum platform version | "minSupportedVersion": { "ios": "17.0" } |
maxSupportedVersion |
Maximum platform version | "maxSupportedVersion": { "ios": "18.0" } |
injectName |
Match inject context | "injectName": "apple-isolated" |
internal |
Internal builds only | "internal": true |
preview |
Preview builds only | "preview": true |
Config example:
{
"settings": {
"conditionalChanges": [
{
"condition": { "domain": "example.com" },
"patchSettings": [{ "op": "replace", "path": "/someSetting", "value": true }]
},
{
"condition": [{ "urlPattern": "https://site1.com/*" }, { "urlPattern": "https://site2.com/path/*" }],
"patchSettings": [{ "op": "add", "path": "/newSetting", "value": "enabled" }]
}
]
}
}
Key rules:
patchSettings uses RFC 6902 JSON Patch with RFC 6901 JSON Pointer paths (/setting/nested)For A/B testing, see privacy-configuration experiments guide.
Use inherited messaging methods:
// Fire-and-forget
this.notify('messageName', { data });
// Request/response
const response = await this.request('messageName', { data });
// Subscribe to updates
this.subscribe('eventName', (data) => { ... });
When shimming browser APIs, use the correct error types to match native behavior:
// TypeError for invalid arguments
throw new TypeError("Failed to execute 'lock' on 'ScreenOrientation': 1 argument required");
// DOMException with name for API-specific errors
throw new DOMException('Share already in progress', 'InvalidStateError');
throw new DOMException('Permission denied', 'NotAllowedError');
return Promise.reject(new DOMException('No device selected.', 'NotFoundError'));
Common DOMException names: InvalidStateError, NotAllowedError, NotFoundError, AbortError, DataError, SecurityError.
Avoid constants in the code and prefer using this.getFeatureSetting('constantName') ?? defaultValue to allow for remote configuration to modify the value.
When using getFeatureSettingEnabled(), use its built-in default parameter rather than || true:
// ✅ Correct - uses second parameter for default
includeIframes: this.getFeatureSettingEnabled('includeIframes', 'enabled');
// ❌ Wrong - || true ignores explicit false from config
includeIframes: this.getFeatureSettingEnabled('includeIframes') || true;
Use stored references or the class-based handleEvent pattern to ensure proper removal:
this.scrollListener = () => {...};
document.addEventListener('scroll', this.scrollListener);
document.removeEventListener('scroll', this.scrollListener);
class MyFeature extends ContentFeature {
init() {
document.addEventListener('scroll', this);
}
destroy() {
document.removeEventListener('scroll', this);
}
handleEvent(e) {
if (e.type === 'scroll') {
requestAnimationFrame(() => this.updatePosition());
}
}
}
Avoid using .bind(this) directly in addEventListener—it creates a new reference each time, preventing removal.
console.log statements from production code and prefer this.log.info instead as this will be disabled in release.navigatesuccess event for URL change detection (ensures navigation is committed):globalThis.navigation.addEventListener('navigatesuccess', handleURLChange);
Enable URL tracking for features that need to respond to SPA navigation:
export default class MyFeature extends ContentFeature {
listenForUrlChanges = true; // Enable URL change tracking
init() {
this.applyFeature();
}
urlChanged(navigationType) {
// Called automatically on URL changes
this.recomputeSiteObject(); // Update config for new path
this.applyFeature();
}
}
Navigation types: 'push', 'replace', 'traverse', 'reload'.
document.readyState to avoid missing DOM elements:if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.applyFix());
} else {
this.applyFix();
}
Use DDGProxy for wrapping browser APIs safely. It automatically adds debug flags, checks exemptions, and preserves toString() behavior:
import { DDGProxy, DDGReflect } from '../utils';
// Wrap a method
const proxy = new DDGProxy(this, Navigator.prototype, 'getBattery', {
apply(target, thisArg, args) {
return Promise.reject(new DOMException('Not allowed', 'NotAllowedError'));
},
});
proxy.overload();
// Wrap a property getter
const propProxy = new DDGProxy(this, Screen.prototype, 'width', {
get(target, prop, receiver) {
return 1920;
},
});
propProxy.overloadProperty();
Use built-in retry utilities for operations that may need multiple attempts:
import { retry, withRetry } from '../timer-utils';
// Simple retry with config (returns { result, exceptions } for debugging)
const { result, exceptions } = await retry(() => findElement(selector), { interval: { ms: 1000 }, maxAttempts: 30 });
// Retry with automatic error handling
const element = await withRetry(
() => document.querySelector(selector),
4, // maxAttempts
500, // delay ms
'exponential', // strategy: 'linear' | 'exponential'
);
if (!element) {
return; // or throw appropriate error
}
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
element.value = value;
}
Use captured globals to avoid page-tampered native APIs:
import * as capturedGlobals from '../captured-globals.js';
// Use captured versions instead of global
const myMap = new capturedGlobals.Map();
const mySet = new capturedGlobals.Set();
// Dispatch events safely
capturedGlobals.dispatchEvent(new capturedGlobals.CustomEvent('name', { detail }));
Validate execution context at feature initialization:
import { isBeingFramed } from '../utils';
init(args) {
if (isBeingFramed()) return; // Skip if in a frame
if (!isSecureContext) return; // Skip if not HTTPS
if (!args.messageSecret) return; // Skip if missing required args
}
For cross-world communication, use message secrets to prevent spoofing:
function appendToken(eventName) {
return `${eventName}-${args.messageSecret}`;
}
// Listen with token
captured.addEventListener(appendToken('MessageType'), handler);
// Dispatch with token
const event = new captured.CustomEvent(appendToken('Response'), { detail: payload });
captured.dispatchEvent(event);
Use WeakSet/WeakMap for DOM element references to allow garbage collection:
const elementCache = new WeakMap();
function getOrCompute(element) {
if (elementCache.has(element)) {
return elementCache.get(element);
}
const result = expensiveComputation(element);
elementCache.set(element, result);
return result;
}
// Delete entries from regular collections after use
navigations.delete(event.target);
For dynamic content, use multiple passes at staggered intervals:
const hideTimeouts = [0, 100, 300, 500, 1000, 2000, 3000];
const unhideTimeouts = [1250, 2250, 3000];
hideTimeouts.forEach((timeout) => {
setTimeout(() => hideAdNodes(rules), timeout);
});
// Clear caches after all operations complete
const clearCacheTimer = Math.max(...hideTimeouts, ...unhideTimeouts) + 100;
setTimeout(() => {
appliedRules = new Set();
hiddenElements = new WeakMap();
}, clearCacheTimer);
Note: Timers are a useful heuristic to save resources but should be remotely configurable and often other techniques such as carefully engineered MutationObservers would be preferred.
Use semaphores to batch frequent DOM updates:
let updatePending = false;
function scheduleUpdate() {
if (!updatePending) {
updatePending = true;
setTimeout(() => {
performDOMUpdate();
updatePending = false;
}, 10);
}
}
featureName, messageSecret) in cache keysawait to ensure errors are caught and flow is maintained:await someAsyncFunction();
new Promise((resolve, reject) => {
if (condition) {
resolve(result);
} else {
reject(new Error('specific error message'));
}
});
if (object != null) {
// Use the object
}
Use typed error classes for action-based features:
import { ErrorResponse } from './broker-protection/types.js';
const response = new ErrorResponse({
actionID: action.id,
message: 'Descriptive error message',
});
this.messaging.notify('actionError', { error: response });
Catch errors without breaking other features:
try {
customElements.define('ddg-element', DDGElement);
} catch (e) {
// May fail on extension reload or conflicts
console.error('Custom element definition failed:', e);
}
See Testing Guide for comprehensive testing documentation.