src/lib/form/dom-watcher.ts
File Relationships
Architecture violations
- [warning] max-cyclomatic-complexity: 'handleMutations' has cyclomatic complexity 24 (max 10)
Symbols by Kind
function
12
type
1
interface
1
All Symbols
| Name | Kind | Visibility | Status | Lines | Signature |
|---|---|---|---|---|---|
| DomWatcherCallback | type | - | 13-13 | type DomWatcherCallback |
|
| WatcherConfig | interface | exported- | 15-22 | interface WatcherConfig |
|
| startWatching | function | exported- | 50-77 | startWatching(
callback?: DomWatcherCallback,
autoRefill?: boolean,
config?: WatcherConfig,
): : void |
|
| stopWatching | function | exported- | 82-98 | stopWatching(): : void |
|
| isWatcherActive | function | exported- | 103-105 | isWatcherActive(): : boolean |
|
| getWatcherConfig | function | exported- | 110-112 | getWatcherConfig(): : Required<WatcherConfig> |
|
| setFillingInProgress | function | exported- | 117-119 | setFillingInProgress(value: boolean): : void |
|
| handleMutations | function | - | 123-187 | handleMutations(mutations: MutationRecord[]): : void |
|
| observeShadowRoots | function | - | 192-201 | observeShadowRoots(root: Element | Document): : void |
|
| observeSingleShadowRoot | function | - | 203-208 | observeSingleShadowRoot(shadowRoot: ShadowRoot): : void |
|
| parseSignature | function | - | 213-215 | parseSignature(sig: string): : Set<string> |
|
| refillNewFields | function | - | 222-259 | refillNewFields(previousSignature: string): : Promise<void> |
|
| getCurrentFieldSignature | function | - | 264-268 | getCurrentFieldSignature(): : string |
|
| hasFormContent | function | - | 273-296 | hasFormContent(el: HTMLElement): : boolean |
Full Source
/**
* DOM Watcher — observes DOM mutations after field interactions
* to detect new/changed form fields and re-fill them
*/
import { detectAllFields, detectAllFieldsAsync } from "./form-detector";
import { fillSingleField } from "./form-filler";
import { getIgnoredFieldsForUrl } from "@/lib/storage/storage";
import { createLogger } from "@/lib/logger";
const log = createLogger("DomWatcher");
type DomWatcherCallback = (newFieldsCount: number) => void;
export interface WatcherConfig {
/** Debounce interval in ms (default 600) */
debounceMs?: number;
/** Whether to auto-refill new fields (default false) */
autoRefill?: boolean;
/** Whether to observe inside Shadow DOM trees (default false) */
shadowDOM?: boolean;
}
const DEFAULT_DEBOUNCE_MS = 600;
let observer: MutationObserver | null = null;
let shadowObservers: MutationObserver[] = [];
let isWatching = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let lastFieldSignature = "";
let onNewFieldsCallback: DomWatcherCallback | null = null;
let isFillingInProgress = false;
let activeConfig: Required<WatcherConfig> = {
debounceMs: DEFAULT_DEBOUNCE_MS,
autoRefill: false,
shadowDOM: false,
};
const OBSERVE_OPTIONS: MutationObserverInit = {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["disabled", "hidden", "style", "class"],
};
/**
* Starts watching the DOM for form changes.
* When new fields are detected, calls the callback and optionally re-fills.
*/
export function startWatching(
callback?: DomWatcherCallback,
autoRefill?: boolean,
config?: WatcherConfig,
): void {
if (isWatching) return;
activeConfig = {
debounceMs: config?.debounceMs ?? DEFAULT_DEBOUNCE_MS,
autoRefill: config?.autoRefill ?? autoRefill ?? false,
shadowDOM: config?.shadowDOM ?? false,
};
onNewFieldsCallback = callback ?? null;
lastFieldSignature = getCurrentFieldSignature();
isWatching = true;
observer = new MutationObserver(handleMutations);
observer.observe(document.body, OBSERVE_OPTIONS);
if (activeConfig.shadowDOM) {
observeShadowRoots(document.body);
}
log.debug(
`DOM watcher started (debounce=${activeConfig.debounceMs}ms, autoRefill=${activeConfig.autoRefill}, shadowDOM=${activeConfig.shadowDOM})`,
);
}
/**
* Stops watching the DOM
*/
export function stopWatching(): void {
if (observer) {
observer.disconnect();
observer = null;
}
for (const so of shadowObservers) {
so.disconnect();
}
shadowObservers = [];
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
isWatching = false;
onNewFieldsCallback = null;
log.debug("DOM watcher stopped");
}
/**
* Returns whether the watcher is active
*/
export function isWatcherActive(): boolean {
return isWatching;
}
/**
* Returns the active watcher configuration
*/
export function getWatcherConfig(): Required<WatcherConfig> {
return { ...activeConfig };
}
/**
* Marks that filling is in progress (to avoid self-triggering)
*/
export function setFillingInProgress(value: boolean): void {
isFillingInProgress = value;
}
// ── Internal Helpers ──────────────────────────────────────────────────────────
function handleMutations(mutations: MutationRecord[]): void {
if (isFillingInProgress) return;
const isRelevant = mutations.some((m) => {
if (
m.type === "childList" &&
(m.addedNodes.length > 0 || m.removedNodes.length > 0)
) {
// When Shadow DOM is enabled, watch newly attached shadow hosts
if (activeConfig.shadowDOM) {
for (const node of m.addedNodes) {
if (node instanceof HTMLElement && node.shadowRoot) {
observeSingleShadowRoot(node.shadowRoot);
}
}
}
return true;
}
if (m.type === "attributes") {
const target = m.target as HTMLElement;
if (target.id === "fill-all-notification") return false;
if (
m.attributeName === "disabled" ||
m.attributeName === "hidden" ||
m.attributeName === "style" ||
m.attributeName === "class"
) {
return hasFormContent(target);
}
}
return false;
});
if (!isRelevant) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const newSignature = getCurrentFieldSignature();
if (newSignature !== lastFieldSignature) {
const previousSignature = lastFieldSignature;
const oldCount = previousSignature.split("|").filter(Boolean).length;
const newCount = newSignature.split("|").filter(Boolean).length;
const diff = newCount - oldCount;
lastFieldSignature = newSignature;
if (diff > 0) {
log.info(`Detected ${diff} new form field(s)`);
if (onNewFieldsCallback) {
onNewFieldsCallback(diff);
}
if (activeConfig.autoRefill) {
await refillNewFields(previousSignature);
}
} else if (diff !== 0) {
log.info(`Form structure changed (${diff} fields)`);
if (onNewFieldsCallback) {
onNewFieldsCallback(diff);
}
}
}
}, activeConfig.debounceMs);
}
/**
* Walks a subtree to find existing open shadow roots and attach observers.
*/
function observeShadowRoots(root: Element | Document): void {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.nextNode();
while (node) {
if (node instanceof HTMLElement && node.shadowRoot) {
observeSingleShadowRoot(node.shadowRoot);
}
node = walker.nextNode();
}
}
function observeSingleShadowRoot(shadowRoot: ShadowRoot): void {
const so = new MutationObserver(handleMutations);
so.observe(shadowRoot, OBSERVE_OPTIONS);
shadowObservers.push(so);
log.debug(`Attached shadow root observer (total=${shadowObservers.length})`);
}
/**
* Parses a field signature string into a Set of individual field keys.
*/
function parseSignature(sig: string): Set<string> {
return new Set(sig.split("|").filter(Boolean));
}
/**
* Re-fills only the new fields that appeared after a DOM change.
* Compares the previous signature against current fields to identify
* which fields are new and fills only those.
*/
async function refillNewFields(previousSignature: string): Promise<void> {
isFillingInProgress = true;
try {
const oldKeys = parseSignature(previousSignature);
const { fields } = await detectAllFieldsAsync();
const newFields = fields.filter((f) => {
const key = `${f.selector}:${f.fieldType}`;
return !oldKeys.has(key);
});
if (newFields.length === 0) {
log.debug("No truly new fields to fill");
return;
}
const url = window.location.href;
const ignoredFields = await getIgnoredFieldsForUrl(url);
const ignoredSelectors = new Set(ignoredFields.map((f) => f.selector));
const fieldsToFill = newFields.filter(
(f) => !ignoredSelectors.has(f.selector),
);
if (fieldsToFill.length === 0) {
log.debug("Todos os novos campos são ignorados — skip");
return;
}
log.info(`Filling ${fieldsToFill.length} new field(s) only`);
for (const field of fieldsToFill) {
await fillSingleField(field);
}
lastFieldSignature = getCurrentFieldSignature();
} finally {
isFillingInProgress = false;
}
}
/**
* Generates a signature string from current form fields for change detection
*/
function getCurrentFieldSignature(): string {
const { fields } = detectAllFields();
const fieldSigs = fields.map((f) => `${f.selector}:${f.fieldType}`);
return fieldSigs.sort().join("|");
}
/**
* Checks if an element contains or is a form-related element
*/
function hasFormContent(el: HTMLElement): boolean {
if (
el.tagName === "INPUT" ||
el.tagName === "SELECT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "FORM"
) {
return true;
}
if (
el.classList.contains("ant-select") ||
el.classList.contains("ant-form-item") ||
el.className.includes("MuiFormControl") ||
el.className.includes("react-select")
) {
return true;
}
return (
el.querySelector("input, select, textarea, .ant-select, .ant-form-item") !==
null
);
}