Skip to content

fix(analytics-browser): tolerate nullish querySelectorAll in mutation observers - #1919

Open
Mercy811 wants to merge 1 commit into
mainfrom
xinyiye/sdk-190-forminteractiontracking-unhandled-typeerror-and-silent-form
Open

fix(analytics-browser): tolerate nullish querySelectorAll in mutation observers#1919
Mercy811 wants to merge 1 commit into
mainfrom
xinyiye/sdk-190-forminteractiontracking-unhandled-typeerror-and-silent-form

Conversation

@Mercy811

Copy link
Copy Markdown
Contributor

Summary

Fixes SDK-190 / closes #1918.

formInteractionTracking and fileDownloadTracking guard that an added node's querySelectorAll exists and is callable, but not that its return value is iterable:

if ('querySelectorAll' in node && typeof node.querySelectorAll === 'function') {
  Array.from(node.querySelectorAll('form') as HTMLFormElement[]).map(addFormInteractionListener);
}

When a patched querySelectorAll returns a nullish value, Array.from throws. Because the throw happens inside nested forEach callbacks, it aborts iteration of both mutation.addedNodes and the outer mutations array, then escapes the MutationObserver callback as an unhandled error. Two consequences:

  1. Silent, partial data loss. Every form/link appended after the offending node in the same mutation batch is never registered, so Form Started / Form Submitted / File Downloaded never fire for those elements. The observer survives for later batches, so the loss is invisible — it looks like users simply didn't interact.
  2. An unhandled TypeError attributed to the host page's bundle.

Per #1918, production RUM shows 642 occurrences over 30 days across 21 browser/OS combinations, all handling: unhandled, with retained occurrences dating to 2024-10-24. The as HTMLFormElement[] cast is what kept the compiler from flagging the nullish case — querySelectorAll returns a NodeList, not an array.

file-download-tracking.ts had the byte-identical pattern for anchor tags and failed the same way, so both are fixed here.

Changes

Per node, in both plugins:

  • Guard the return value before iterating.
  • Drop the unsound casts (as HTMLFormElement[] / as HTMLAnchorElement[]). The node is narrowed to Element so querySelectorAll('form') resolves to NodeListOf<HTMLFormElement> via the tag-name overload, and the result is annotated | null | undefined — a sound widening that admits the type lies at runtime, rather than a narrowing cast that hides it. No any is reintroduced, which matters because no-unsafe-* is active in src/.
  • Wrap the per-node body in try/catch that logs via config.loggerProvider.warn, so one malformed node cannot abort registration for the rest of the batch. This addresses the silent-data-loss half independently of this specific nullish case — e.g. a hostile node whose querySelectorAll throws outright.
  • Array.from is no longer needed: querySelectorAll returns a static NodeList, so there is nothing to snapshot, and NodeList.forEach is already relied on for mutation.addedNodes in these same callbacks.

Also corrects a copy-paste comment in the form plugin ("anchor tags" → "form tags") on the block being rewritten.

Tests

Four regression tests, two per plugin, in the existing suites:

  • nullish return — a node whose querySelectorAll returns undefined, appended between two wrapped elements in a single mutation batch; asserts the later element is still registered and tracked.
  • batch continuation on throw — a node whose querySelectorAll throws; asserts the warning is logged and the element appended after it is still registered.

Verified these are genuine regression tests: with the src changes stashed, exactly these 4 fail (with the production error, TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator)) at Array.from) and the 24 pre-existing tests pass. With the fix, all 28 pass, and both changed files stay at 100% statements/branches/functions/lines — no new istanbul ignore needed.

Out of scope

querySelectUniqueElements in plugin-autocapture-browser/src/helpers.ts has the same guard-existence-but-not-return-value gap, but it is unreachable: no production module imports it (only its own test does) and it is not re-exported from that package's index.ts. Fixing it would mean a second package release with no user-facing effect; the more appropriate follow-up is deleting it, which pnpm lint:deps / knip territory covers better than this PR.

Checklist

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change?: No — behavior is unchanged for conforming DOM nodes; previously-throwing nodes are now skipped with a warning.

🤖 Generated with Claude Code

… observers

The form interaction and file download plugins guarded that an added node's
querySelectorAll exists and is callable, but not that its return value is
iterable. When a patched querySelectorAll returns a nullish value, Array.from
throws, and the throw propagates out of both nested forEach loops and escapes
the MutationObserver callback as an unhandled error.

Every form or link appended after the offending node in the same mutation batch
is then never registered, so Form Started / Form Submitted and File Downloaded
never fire for those elements. The observer survives for later batches, which
makes the loss partial and invisible.

Guard the return value, drop the unsound casts to array types, and wrap the
per-node body in a try/catch that logs via config.loggerProvider so one
malformed node cannot abort registration for the rest of the batch.

Fixes #1918

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Mercy811
Mercy811 requested a review from a team as a code owner July 31, 2026 20:03
@linear-code

linear-code Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

SDK-190

@github-actions

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/analytics-browser/lib/scripts/amplitude-min.js.gz 61.31 KB (+0.06% 🔺)
packages/session-replay-browser/lib/scripts/session-replay-browser-min.js.gz 134.97 KB (0%)
packages/unified/lib/scripts/amplitude-min.umd.js.gz 215.39 KB (+0.02% 🔺)
@amplitude/element-selector (gzipped esm) 2.67 KB (0%)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

formInteractionTracking: unhandled TypeError and silent form loss when an added node's querySelectorAll returns nullish

1 participant