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:
- All conditions in a block must match (AND logic)
- Array of condition blocks uses OR logic (any block matching applies the patch)
patchSettingsuses RFC 6902 JSON Patch with RFC 6901 JSON Pointer paths (/setting/nested)- Unsupported conditions cause the block to fail (for backwards compatibility)
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) => { ... });The nativeData field is reserved for native platform use and must never be included in messages sent from C-S-S to the client. Native implementations inject a nativeData field into incoming messages; nativeData is reserved for that layer. Including it in outgoing messages would conflict with native-side processing.
When constructing notification or request params, only pass explicitly defined fields:
// ✅ Correct — only known fields
this.messaging.notify('webEvent', { type, data });
// ❌ Wrong — spreading unknown fields risks leaking nativeData
this.messaging.notify('webEvent', eventObject);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.
- Extract reusable logic into separate files for focused unit testing
- Security-sensitive operations (e.g., markdown to HTML conversion) should be isolated with extensive tests
- Avoid hardcoding debug flags; ensure they are configurable and environment-dependent
- Remove
console.logstatements from production code and preferthis.log.infoinstead as this will be disabled in release.
- Execute secondary actions from the top level to avoid context handling issues
- Avoid re-calling execution functions within an action
- Use
navigatesuccessevent 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'.
- Check
document.readyStateto 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'
);- Validate elements and their types before operations:
if (!element) {
return; // or throw appropriate error
}
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
element.value = value;
}- Avoid global or static caching mechanisms that could lead to race conditions
- Use instance-scoped storage or include all relevant identifiers in cache keys
- Ensure custom permission handling does not bypass native permission models
- Handle permissions with custom behaviors or name overrides correctly
- Be cautious enabling APIs like Web Share within iframes, understand that you're exposing message overhead and potential side effects to a third party.
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);
}
}- Avoid global or static caches for message bridges
- Include all relevant parameters (
featureName,messageSecret) in cache keys
- Use
awaitto ensure errors are caught and flow is maintained:
await someAsyncFunction();- Ensure promises have both resolve and reject paths:
new Promise((resolve, reject) => {
if (condition) {
resolve(result);
} else {
reject(new Error('specific error message'));
}
});- Perform null checks before using objects:
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.