Frontend

Why preventDefault Does Not Stop a Global Keydown Listener

Two features bound the same shortcut and both fired. preventDefault cancels default actions, not other listeners. Here is the fix that survives the switch.

Two features in the same editor bound Cmd/Ctrl+Enter. One posted a comment. The other started an expensive regeneration job that consumed credits and replaced an existing result. Pressing the key did both. The comment box already called preventDefault(), and that changed nothing, because preventDefault was never the tool for this job.

The bug is easy to reproduce and easy to misdiagnose. It also has a second half that most teams miss: moving the shortcut fixes the collision but can make the first week worse.

What a shared shortcut actually looks like

The two features were written months apart, in different directories, by people looking at different problems.

The editor had a global shortcut. It listened on window, checked for Enter plus a modifier, and started the regeneration job for whichever segment was focused:

useEffect(() => {
  const onKeyDown = (e) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      if (focusedId) startExpensiveJob(focusedId)
    }
  }
  window.addEventListener('keydown', onKeyDown)
  return () => window.removeEventListener('keydown', onKeyDown)
}, [focusedId])

Later, a comment panel was added as a self-contained component. It followed the common web convention that Cmd/Ctrl+Enter submits a text box:

<textarea
  onKeyDown={(e) => {
    if (e.nativeEvent.isComposing) return
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      submit()
    }
  }}
/>

Both snippets are reasonable in isolation. Neither file mentions the other. The panel was never checked against the editor's shortcut table, because the panel did not think of itself as part of the editor.

The result: a reviewer clicks a segment, types a note in the comment box, presses Cmd+Enter, and the note is saved while the segment is silently regenerated. The action meant to record an opinion also overwrote the thing being reviewed.

Why preventDefault does not stop the other listener

The comment box calls preventDefault(). It still does not win, because the three methods on a DOM event do three different things:

Method What it stops What it does not stop
preventDefault() The browser's default action (submit, scroll, insert newline) Any listener, on any node
stopPropagation() Listeners on ancestors further along the path Other listeners on the same node
stopImmediatePropagation() Every listener not yet called, including same-node siblings The default action

preventDefault sets a flag. The event keeps traveling, and every listener along the path still runs. A listener that never reads event.defaultPrevented will not notice that anyone objected.

Why one keypress ran two handlers 1 bubbles 2 not blocked 3 wanted 4 unwanted Comment box React root Window Comment saved Job re-runs // preventDefault sets a flag, it does not stop other listeners

React adds a wrinkle worth knowing. Since React 17, synthetic events are attached at the root container of your app, not at document. Your onKeyDown prop is not a listener on the textarea itself. It runs while React replays the event through its own tree, and by then the native event has already been dispatched to the root. A window listener sits above that root, so it hears the event either way.

This is why the naive fix of adding stopPropagation() inside the component is fragile. It can work, because React calls the native stopPropagation under the hood and the root is below window in the path. But it depends on where React attached its listeners in your version, and it silently breaks any other ancestor listener you did want, including ones added later by a library. Blocking propagation is a blunt instrument aimed at everyone above you.

Changing the key is only half the fix

The obvious remedy is to move one of the two shortcuts. In our case the comment box moved to Shift+Enter, which is what the person who reported the bug asked for.

That removes the collision. It does not remove the damage, and here is the part that is easy to miss: for anyone with the old habit in their fingers, the first week gets worse, not better.

Before the change, pressing Cmd+Enter in the comment box did two things, one of which you wanted. After the change, the same keypress posts nothing and still starts the expensive job. The user loses their note and pays for a regeneration they did not ask for. You traded a confusing outcome for a purely destructive one.

So the shortcut move needs a companion: the global handler must ignore keystrokes that came from the comment box.

A whitelist marker beats a blanket input check

The first instinct is to make the global handler skip all text inputs:

const tag = e.target.tagName
if (tag === 'TEXTAREA' || tag === 'INPUT') return

Do not do this here. In our editor, the main use of the shortcut is to press Cmd+Enter while editing text in a segment: the handler blurs the field to flush the edit, then regenerates with the new content. A blanket input check kills the primary feature to fix a secondary conflict.

The rule that works is a whitelist. Mark the specific fields that should suppress the global shortcut, and let everything else keep its existing behavior:

<textarea data-comment-input onKeyDown={...} />
const target = e.target
if (target?.closest?.('[data-comment-input]')) return
e.preventDefault()

Three details make this hold up:

  • closest() starts at the node itself, so putting the attribute on the textarea is enough. You do not need a wrapper.
  • The guard must run before preventDefault(). If you cancel the default action first, Enter stops inserting a newline in the comment box even though you decided to skip the handler.
  • The optional call ?. matters. When focus sits on document or the event target is not an element, closest is not there, and a hard call throws inside a global listener.

An attribute selector beats a component-level flag here because the check happens far from the component. The global handler has no access to React state in the comment panel, but it does have the DOM node the event came from.

The same reasoning applies to the direction of the default. A whitelist fails safe: any field you forget to mark keeps working exactly as it did before. A blacklist ("skip everything except segment editors") fails the other way, breaking features you never thought about.

What to check in your own app

If you have any global keyboard listener, walk this list once:

  1. Inventory the shortcuts. Grep for addEventListener('keydown' and for framework key handlers separately. Collisions live in the gap between those two lists, because the people writing each rarely read the other.
  2. Count the handlers per binding. For each combination, ask how many code paths would run today. More than one is a bug waiting for a user with a habit.
  3. Check every input variant, not just the obvious one. Our comment panel had four text fields: new comment, edit comment, new reply, edit reply. The report only mentioned the first. Fixing one of four would have split the behavior inside a single panel.
  4. Decide which side owns the collision. The feature that costs money or mutates data should be the one that yields, since a missed keystroke is cheaper than an unwanted job.
  5. Verify the transition, not just the end state. Press the old shortcut and confirm the result is harmless. That is the case your test plan will skip.

Should I use stopImmediatePropagation instead?

Only if you own every listener on the path and want none of them to run. It stops same-node siblings too, which is usually more than you meant. The targeted guard in the global handler is easier to reason about a year later, because the exception lives next to the behavior it modifies.

Why not have the global handler check event.defaultPrevented?

That works when the local handler always cancels the default, and it is a one-line change. It is also a weaker contract: any local handler that skips preventDefault for a legitimate reason silently re-enables the global action. The marker attribute says what you mean, which is "this region does not participate in the global shortcut."

Does this apply outside React?

Yes. The synthetic event system changes where the local handler runs, but not the outcome. Two listeners on the same path both run in plain JavaScript, Vue, or Svelte. The React detail only explains why stopPropagation inside a component sometimes appears to work and sometimes does not.

Is a shortcut library the real answer?

A library with a central registry does prevent the class of bug, because registering the same binding twice becomes visible at registration time. That is a good direction for an app with many shortcuts. It is a larger change than a guard, and it does not help unless every feature routes through it, which is exactly the assumption that failed in the first place.

The general lesson is small enough to keep: preventDefault talks to the browser, not to your other code. When two features need to share a key, one of them has to be told about the other, and the safest way to tell it is with a mark on the DOM node that the event carries with it.