Skip to content

OTP input: don't fire input or complete events when Delete or Backspace has nothing to remove - #43255

Merged
julien-deramond merged 1 commit into
mainfrom
v6-jd-otp-noop-delete-events
Oct 31, 2026
Merged

julien-deramond merged 1 commit into
mainfrom
v6-jd-otp-noop-delete-events

Conversation

@julien-deramond

Copy link
Copy Markdown
Member

Since #43252, a Delete with nothing after the caret still fires input.bs.otpInput, and on a full code it fires complete.bs.otpInput again. In Chromium, End then Delete on a full code triggers a second complete, so a form that submits on complete submits twice. The handler cancels the browser's action, finds nothing to remove, and still runs _afterValueChange(). Now it returns before that when nothing changes.

The same check covers Backspace with nothing before the caret. In an empty field it has fired an input event with an empty value since the OTP input was added, and it no longer does. Both cases now behave as a plain input, which fires nothing when there's nothing to delete.

Follow-up to #43252.

Notes: what was checked, and how to check it again

Measured

Real mouse clicks and key presses with Playwright 1.64.1 in Chromium 156, Firefox 157 and WebKit 27.2, on the bundles built from main before #43252 (f0dcf4b), from main today (a4d4afa) and from this branch. Each cell is the number of input / complete events the key fired.

Case Engine Before #43252 main This PR
Full code 123456, End then Delete Chromium 0 / 0 1 / 1 0 / 0
1256, click the first empty slot, Delete Chromium, Firefox 0 / 0 1 / 0 0 / 0
Empty field, Backspace Chromium, Firefox 1 / 0 1 / 0 0 / 0
Same three cases WebKit 0 / 0 0 / 0 0 / 0

In Firefox and WebKit, End selects the last slot, so Delete removes it and fires one input event on all three builds.

Impact

The change touches only the delete branch of the OTP input's beforeinput handler. I ran the same sequence on every example of the OTP docs page: default, connected, four-digit PIN, groups (data-bs-groups="[3,3]"), .otp-sm, .otp-lg, alphanumeric, masked, .is-invalid and with a form label. The sequence presses Backspace in the empty field, types a full code, then presses End and Delete. Then it types 9 over the third slot, presses Backspace and Delete on a selected slot, Delete on the first empty slot, and ←. It records the value, the active slot and the events after each key.

Typing, overwriting, and Backspace or Delete on a filled slot give the same value, active slot and events on all three builds, in all three engines. Only the cases in the table change. The readonly, disabled and <fieldset disabled> fields fire no event on main or with this PR.

Checks

  • npm run js-test-unit: 1,373 tests pass. Two of the three new event tests fail on main (Delete after the last character, Backspace before the first one). The third, one input event when Delete removes a character, passes on both and guards the normal case.
  • npm run js-lint: no errors. Its six warnings are in other files and also show on main.
  • npm run js-typecheck passes.
  • Bundlewatch: bootstrap.bundle.min.js stays at 58.54 kB (budget 59 kB), bootstrap.min.js goes from 34.77 kB to 34.76 kB (budget 35 kB).
  • No CSS or docs change.
  • Not checked: touch devices, IME composition and screen readers.
How to check it again

Build the bundle on each side with npm run js-compile-bundle, copy dist/js/bootstrap.bundle.js to .probe/before.js (at f0dcf4b), .probe/main.js (at a4d4afa) and .probe/fix.js (on this branch), and revert dist/. Save the page as .probe/index.html, serve the repository root with npx http-server -p 47412 -s -c-1 ., and run node probe.mjs http://127.0.0.1:47412/.probe/index.html from the repository root.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OTP input probe</title>
<link rel="stylesheet" href="/dist/css/bootstrap.css">
</head>
<body class="p-4">
<div class="otp" data-bs-otp id="default"><input type="text" class="otp-input" maxlength="6" aria-label="default"></div>
<div class="otp otp-connected" data-bs-otp id="connected"><input type="text" class="otp-input" maxlength="6" aria-label="connected"></div>
<div class="otp otp-connected" data-bs-otp id="pin"><input type="text" class="otp-input" maxlength="4" aria-label="pin"></div>
<div class="otp" data-bs-otp data-bs-groups="[3,3]" id="groups"><input type="text" class="otp-input" maxlength="6" aria-label="groups"></div>
<div class="otp otp-sm" data-bs-otp id="sm"><input type="text" class="otp-input" maxlength="6" aria-label="sm"></div>
<div class="otp otp-lg" data-bs-otp id="lg"><input type="text" class="otp-input" maxlength="6" aria-label="lg"></div>
<div class="otp otp-connected" data-bs-otp data-bs-type="alphanumeric" id="alnum"><input type="text" class="otp-input" maxlength="6" aria-label="alnum"></div>
<div class="otp otp-connected" data-bs-otp data-bs-mask="true" id="mask"><input type="text" class="otp-input" maxlength="6" aria-label="mask"></div>
<div class="otp otp-connected is-invalid" data-bs-otp id="invalid"><input type="text" class="otp-input" maxlength="6" value="123" aria-label="invalid"></div>
<label for="labelled" class="form-label">Code</label>
<div class="otp" data-bs-otp id="label"><input type="text" class="otp-input" id="labelled" maxlength="6"></div>
<div class="otp otp-connected" data-bs-otp id="ro"><input type="text" class="otp-input" maxlength="6" value="123456" aria-label="ro" readonly></div>
<div class="otp otp-connected" data-bs-otp id="dis"><input type="text" class="otp-input" maxlength="6" value="123456" aria-label="dis" disabled></div>
<fieldset disabled><div class="otp otp-connected" data-bs-otp id="fs"><input type="text" class="otp-input" maxlength="6" value="123456" aria-label="fs"></div></fieldset>
<script type="module">
  const side = new URLSearchParams(location.search).get('side')
  const bs = await import(`./${side}.js`)
  window.counts = {}
  for (const el of document.querySelectorAll(".otp")) {
    window.counts[el.id] = { i: 0, c: 0 }
    el.addEventListener("input.bs.otpInput", () => window.counts[el.id].i++)
    el.addEventListener("complete.bs.otpInput", () => window.counts[el.id].c++)
  }
  window.bootstrap = bs; window.ready = true
</script>
</body>
</html>
import { chromium, firefox, webkit } from 'playwright'
const url = process.argv[2]
const ids = ['default', 'connected', 'pin', 'groups', 'sm', 'lg', 'alnum', 'mask', 'invalid', 'label']
const out = {}
for (const [name, engine] of Object.entries({ chromium, firefox, webkit })) {
  const b = await engine.launch()
  for (const side of ['before', 'main', 'fix']) {
    const p = await b.newPage()
    await p.goto(`${url}?side=${side}`); await p.waitForFunction(() => window.counts)
    const click = async (id, n) => { const bb = await p.locator(`#${id} .otp-slot`).nth(n).boundingBox(); await p.mouse.click(bb.x + bb.width / 2, bb.y + bb.height / 2) }
    let last = {}
    const step = async id => {
      const s = await p.evaluate(i => {
        const el = document.querySelector(`#${i}`)
        const active = [...el.querySelectorAll('.otp-slot')].findIndex(x => x.classList.contains('otp-slot-active'))
        return { v: el.querySelector('input').value, a: active, ...window.counts[i] }
      }, id)
      const prev = last[id] ?? { i: 0, c: 0 }
      last[id] = s
      return `${s.v || '∅'}@${s.a} ${s.i - prev.i}/${s.c - prev.c}`
    }
    for (const id of ids) {
      const code = id === 'alnum' ? 'a1b2c3' : (id === 'pin' ? '1234' : '123456')
      const steps = []
      if (id !== 'invalid') { await click(id, 0); await p.keyboard.press('Backspace'); steps.push('bs-empty ' + await step(id)) } else { await click(id, 3); await step(id) }
      await click(id, 0)
      if (id === 'invalid') await p.keyboard.press('Home')
      await p.keyboard.type(id === 'invalid' ? '9' : code); steps.push('type ' + await step(id))
      await p.keyboard.press('End'); await p.keyboard.press('Delete'); steps.push('end+del ' + await step(id))
      await click(id, 2); await p.keyboard.press('9'); steps.push('over ' + await step(id))
      await click(id, 2); await p.keyboard.press('Backspace'); steps.push('bs ' + await step(id))
      await p.keyboard.press('Delete'); steps.push('del ' + await step(id))
      await click(id, 5 < code.length ? 5 : code.length - 1); await p.keyboard.press('Delete'); steps.push('del-empty ' + await step(id))
      await p.keyboard.press('ArrowLeft'); steps.push('left ' + await step(id))
      await p.evaluate(() => document.activeElement.blur())
      ;(out[id] ??= {})[`${name} ${side}`] = steps.join(' | ')
    }
    for (const id of ['ro', 'dis', 'fs']) {
      await click(id, 2); await p.keyboard.press('9'); await p.keyboard.press('Delete')
      ;(out[id] ??= {})[`${name} ${side}`] = 'type+del ' + await step(id)
      await p.evaluate(() => document.activeElement.blur())
    }
    await p.close()
  }
  await b.close()
}
for (const [id, rows] of Object.entries(out)) for (const [k, v] of Object.entries(rows)) console.log(id.padEnd(9), k.padEnd(16), v)

🤖 Generated with Claude Code

…ce has nothing to remove

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@julien-deramond julien-deramond added js JavaScript or TypeScript sources and plugins v6 v6, the main branch bug Something does not work as documented labels Oct 31, 2026
@julien-deramond
julien-deramond requested a review from a team as a code owner October 31, 2026 13:12
@julien-deramond julien-deramond added v6 v6, the main branch bug Something does not work as documented labels Oct 31, 2026
@julien-deramond julien-deramond added this to the v6.0.0-alpha.2 milestone Oct 31, 2026
@github-project-automation github-project-automation Bot moved this to Inbox in v6.0.0 Oct 31, 2026
@julien-deramond
julien-deramond merged commit c2ab624 into main Oct 31, 2026
14 checks passed
@julien-deramond
julien-deramond deleted the v6-jd-otp-noop-delete-events branch October 31, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something does not work as documented js JavaScript or TypeScript sources and plugins v6 v6, the main branch

Projects

Status: Inbox

Development

Successfully merging this pull request may close these issues.

1 participant