src/lib/form/dom-watcher.ts

Total Symbols
14
Lines of Code
297
Avg Complexity
4.4
Symbol Types
3

File Relationships

graph LR startWatching["startWatching"] getCurrentFieldSignature["getCurrentFieldSignature"] observeShadowRoots["observeShadowRoots"] handleMutations["handleMutations"] observeSingleShadowRoot["observeSingleShadowRoot"] hasFormContent["hasFormContent"] refillNewFields["refillNewFields"] parseSignature["parseSignature"] startWatching -->|calls| getCurrentFieldSignature startWatching -->|calls| observeShadowRoots handleMutations -->|calls| observeSingleShadowRoot handleMutations -->|calls| hasFormContent handleMutations -->|calls| getCurrentFieldSignature handleMutations -->|calls| refillNewFields observeShadowRoots -->|calls| observeSingleShadowRoot refillNewFields -->|calls| parseSignature refillNewFields -->|calls| getCurrentFieldSignature click startWatching "../symbols/f5ff88d04d3dd953.html" click getCurrentFieldSignature "../symbols/e72508d8f19c287a.html" click observeShadowRoots "../symbols/9252c52a7fc52fba.html" click handleMutations "../symbols/b707db53be39a752.html" click observeSingleShadowRoot "../symbols/0945931e53402ca4.html" click hasFormContent "../symbols/a70353de2f735ffb.html" click refillNewFields "../symbols/07b7f93b13ced4af.html" click parseSignature "../symbols/52c931d08cb489e0.html"

Architecture violations

View all

  • [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
  );
}