import { instrumentSetter, DOM_EVENT, addEventListeners, noop } from '@datadog/browser-core' import { NodePrivacyLevel, getNodePrivacyLevel, shouldMaskNode } from '@datadog/browser-rum-core' import { IncrementalSource } from '../../../types' import type { InputData, InputState } from '../../../types' import { getEventTarget } from '../eventsUtils' import type { RecordingScope } from '../recordingScope' import { getElementInputValue } from '../serialization' import { assembleIncrementalSnapshot } from '../assembly' import type { EmitRecordCallback } from '../record.types' import type { Tracker } from './tracker.types' export function trackInput( target: Document | ShadowRoot, emitRecord: EmitRecordCallback, scope: RecordingScope ): Tracker { const defaultPrivacyLevel = scope.configuration.defaultPrivacyLevel const lastInputStateMap: WeakMap = new WeakMap() const isShadowRoot = target !== document const { stop: stopEventListeners } = addEventListeners( scope.configuration, target, // The 'input' event bubbles across shadow roots, so we don't have to listen for it on shadow // roots since it will be handled by the event listener that we did add to the document. Only // the 'change' event is blocked and needs to be handled on shadow roots. isShadowRoot ? [DOM_EVENT.CHANGE] : [DOM_EVENT.INPUT, DOM_EVENT.CHANGE], (event) => { const target = getEventTarget(event) if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement ) { onElementChange(target) } }, { capture: true, passive: true, } ) let stopPropertySetterInstrumentation: () => void if (!isShadowRoot) { const instrumentationStoppers = [ instrumentSetter(HTMLInputElement.prototype, 'value', onElementChange), instrumentSetter(HTMLInputElement.prototype, 'checked', onElementChange), instrumentSetter(HTMLSelectElement.prototype, 'value', onElementChange), instrumentSetter(HTMLTextAreaElement.prototype, 'value', onElementChange), instrumentSetter(HTMLSelectElement.prototype, 'selectedIndex', onElementChange), ] stopPropertySetterInstrumentation = () => { instrumentationStoppers.forEach((stopper) => stopper.stop()) } } else { stopPropertySetterInstrumentation = noop } return { stop: () => { stopPropertySetterInstrumentation() stopEventListeners() }, } function onElementChange(target: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) { const nodePrivacyLevel = getNodePrivacyLevel(target, defaultPrivacyLevel) if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) { return } const type = target.type let inputState: InputState if (type === 'radio' || type === 'checkbox') { if (shouldMaskNode(target, nodePrivacyLevel)) { return } inputState = { isChecked: (target as HTMLInputElement).checked } } else { const value = getElementInputValue(target, nodePrivacyLevel) if (value === undefined) { return } inputState = { text: value } } // Can be multiple changes on the same node within the same batched mutation observation. createRecordIfStateChanged(target, inputState) // If a radio was checked, other radios with the same name attribute will be unchecked. const name = target.name if (type === 'radio' && name && (target as HTMLInputElement).checked) { document.querySelectorAll(`input[type="radio"][name="${CSS.escape(name)}"]`).forEach((el: Element) => { if (el !== target) { // TODO: Consider the privacy implications for various differing input privacy levels createRecordIfStateChanged(el, { isChecked: false }) } }) } } /** * There can be multiple changes on the same node within the same batched mutation observation. */ function createRecordIfStateChanged(target: Node, inputState: InputState) { const id = scope.nodeIds.get(target) if (id === undefined) { return } const lastInputState = lastInputStateMap.get(target) if ( !lastInputState || (lastInputState as { text?: string }).text !== (inputState as { text?: string }).text || (lastInputState as { isChecked?: boolean }).isChecked !== (inputState as { isChecked?: boolean }).isChecked ) { lastInputStateMap.set(target, inputState) emitRecord( assembleIncrementalSnapshot(IncrementalSource.Input, { id, ...inputState, }) ) } } }