Paste event javascript

Copy the value, press Ctrl+V, and read the field twice. In the paste handler el.value still held 0 characters while event.clipboardData.getData("text/plain") held all four. The field updated only after the handler returned. Chrome 152 also turned a pasted CRLF into one space, and a cap of 10 units kept 9 of an emoji string.

Why check this

Most values in a form arrive by paste, not by typing. An order number or an address is copied out of a mail client, and the field receives it in one keystroke.

Run this before sign-off on any form that takes text from elsewhere, and after a change to how a field receives its value.

The failure it prevents is a rule that runs one paste behind. Step 2 reproduces it: inside the paste handler the field held 0 characters while the clipboard held 4. A length check written there passes an empty field and stores the pasted value unread.

Prerequisites

paste-fixture.mjs holds the strings.

export const SP   = '\u0020'; // SPACE
export const NBSP = '\u00A0'; // NO-BREAK SPACE
export const ZWSP = '\u200B'; // ZERO WIDTH SPACE
export const CR   = '\u000D'; // CARRIAGE RETURN
export const LF   = '\u000A'; // LINE FEED
export const RECEIPT = '\u{1F9FE}'; // RECEIPT, one code point, two UTF-16 units

export const PLAIN   = 'ab12';
export const MIXED   = SP + NBSP + ZWSP + 'one' + CR + LF + 'two' + ZWSP + NBSP + SP;
export const TWOLINE = 'one' + CR + LF + 'two';
export const ASCII14 = 'A'.repeat(14);
export const EMO8    = RECEIPT.repeat(8);
export const ODD     = 'A' + RECEIPT.repeat(6);

// one line per UTF-16 code unit, which is the unit maxlength counts
export const cus = (s) =>
  Array.from({ length: s.length }, (_, i) =>
    'U+' + s.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0')).join(' ');

paste-target.mjs serves the form and prints what arrives on the wire.

import { createServer } from 'node:http';

const PORT = 8933;
const cus = (s) =>
  Array.from({ length: s.length }, (_, i) =>
    'U+' + s.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0')).join(' ');

const FORM = `<!doctype html><html><head><meta charset="utf-8"><title>paste fixture</title></head>
<body>
<form id="f" method="post" action="/save">
  <input id="text" name="text" type="text">
  <input id="email" name="email" type="email">
  <input id="link" name="link" type="url">
  <textarea id="note" name="note"></textarea>
  <input id="capped" name="capped" type="text" maxlength="10">
  <input id="blocked" name="blocked" type="text">
  <button id="go" type="submit">Save</button>
</form>
</body></html>`;

createServer((req, res) => {
  const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
  if (req.method === 'GET' && url.pathname === '/') {
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    return res.end(FORM);
  }
  if (!(req.method === 'POST' && url.pathname === '/save')) {
    res.writeHead(404, { 'content-type': 'text/plain' });
    return res.end('not found');
  }
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const body = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
    const out = [];
    for (const [k, v] of body) {
      out.push(k.padEnd(8) + 'units ' + String(v.length).padStart(2) + '  bytes '
        + String(Buffer.byteLength(v, 'utf8')).padStart(2) + '  ' + cus(v));
    }
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
    res.end(out.join('\n') + '\n');
  });
}).listen(PORT, '127.0.0.1', () => console.log('paste fixture on http://127.0.0.1:' + PORT));

paste-probe.mjs drives Chrome. Each subcommand is one step below.

import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PLAIN, MIXED, TWOLINE, ASCII14, EMO8, ODD, cus } from './paste-fixture.mjs';

const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8933';
const mode = process.argv[2];
const pad = (n, w) => String(n).padStart(w);
const row = (label, s) => label.padEnd(30) + 'units ' + pad(s.length, 2) + '   ' + cus(s);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

if (mode === 'fixture') {
  for (const [k, v] of [['PLAIN', PLAIN], ['MIXED', MIXED], ['TWOLINE', TWOLINE],
    ['ASCII14', ASCII14], ['EMO8', EMO8], ['ODD', ODD]]) console.log(row(k, v));
  process.exit(0);
}

const profile = mkdtempSync(join(tmpdir(), 'h2c-paste-'));
const browser = await launch({ executablePath: CHROME, headless: true, userDataDir: profile, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
const cdp = await page.createCDPSession();
await cdp.send('Browser.grantPermissions', { origin: ORIGIN, permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'] });
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });
await page.bringToFront();

const copy = (t) => page.evaluate((s) => navigator.clipboard.writeText(s), t);
const readClipboard = () => page.evaluate(() => navigator.clipboard.readText());
const clear = (id) => page.evaluate((i) => { const el = document.getElementById(i); el.value = ''; el.focus(); }, id);
const value = (id) => page.evaluate((i) => document.getElementById(i).value, id);
const armCapture = (id) => page.evaluate((i) => {
  window.__cd = null;
  document.getElementById(i).addEventListener('paste', (e) => {
    window.__cd = e.clipboardData.getData('text/plain');
  }, { once: true });
}, id);

async function pressPaste(id) {
  await page.click('#' + id);
  await page.keyboard.down('Control');
  await page.keyboard.press('KeyV');
  await page.keyboard.up('Control');
  await wait(250);
}

if (mode === 'timing') {
  await copy(PLAIN);
  await clear('text');
  await page.evaluate(() => {
    window.__log = [];
    const el = document.getElementById('text');
    const push = (k, v) => window.__log.push([k, v]);
    el.addEventListener('paste', (e) => {
      push('1 paste handler value', el.value);
      push('2 paste clipboardData', e.clipboardData.getData('text/plain'));
      queueMicrotask(() => push('microtask value', el.value));
      setTimeout(() => push('setTimeout 0 value', el.value), 0);
    }, { once: true });
    el.addEventListener('input', (e) => push('input ' + e.inputType + ' value', el.value), { once: true });
  });
  await pressPaste('text');
  await wait(200);
  for (const [k, v] of await page.evaluate(() => window.__log)) console.log(row(k, v));
} else if (mode === 'arrive') {
  await copy(MIXED);
  console.log(row('fixture MIXED', MIXED));
  console.log(row('clipboard readText()', await readClipboard()));
  for (const [id, label] of [['text', 'type=text'], ['email', 'type=email'], ['link', 'type=url'], ['note', 'textarea']]) {
    await clear(id);
    await armCapture(id);
    await pressPaste(id);
    console.log(row(label + ' clipboardData', (await page.evaluate(() => window.__cd)) ?? ''));
    console.log(row(label + ' pasted el.value', await value(id)));
    const assigned = await page.evaluate(([i, t]) => {
      const el = document.getElementById(i);
      el.value = t;
      return el.value;
    }, [id, MIXED]);
    console.log(row(label + ' assigned el.value', assigned));
  }
} else if (mode === 'newline') {
  await copy(TWOLINE);
  console.log(row('fixture TWOLINE', TWOLINE));
  console.log(row('clipboard readText()', await readClipboard()));
  for (const [id, label] of [['text', 'type=text'], ['note', 'textarea']]) {
    await clear(id);
    await armCapture(id);
    await pressPaste(id);
    console.log(row(label + ' clipboardData', (await page.evaluate(() => window.__cd)) ?? ''));
    console.log(row(label + ' el.value', await value(id)));
  }
} else if (mode === 'cap') {
  for (const [label, text] of [['ASCII14', ASCII14], ['EMO8', EMO8], ['ODD', ODD]]) {
    await copy(text);
    await clear('capped');
    await pressPaste('capped');
    const v = await value('capped');
    console.log(label.padEnd(8) + 'pasted units ' + pad(text.length, 2) + '   kept units ' + pad(v.length, 2)
      + '   points ' + pad([...v].length, 2) + '   wellFormed ' + v.isWellFormed());
    console.log(' '.repeat(8) + 'kept ' + cus(v));
  }
} else if (mode === 'blocked') {
  await copy(PLAIN);
  await clear('blocked');
  await page.evaluate(() => {
    window.__n = 0;
    document.getElementById('blocked').addEventListener('paste', (e) => { window.__n += 1; e.preventDefault(); });
  });
  await pressPaste('blocked');
  console.log('paste events seen       ' + (await page.evaluate(() => window.__n)));
  console.log(row('after Ctrl+V', await value('blocked')));
  await page.click('#blocked');
  for (const c of PLAIN) await page.keyboard.sendCharacter(c);
  console.log(row('after typing', await value('blocked')));
  await clear('blocked');
  const a = await page.evaluate((t) => {
    const el = document.getElementById('blocked'); el.focus();
    return document.execCommand('insertText', false, t);
  }, PLAIN);
  console.log('execCommand insertText  returned ' + a);
  console.log(row('after insertText', await value('blocked')));
  await clear('blocked');
  const b = await page.evaluate(() => {
    const el = document.getElementById('blocked'); el.focus();
    return document.execCommand('paste');
  });
  console.log('execCommand paste       returned ' + b);
  console.log(row('after execCommand paste', await value('blocked')));
} else if (mode === 'detect') {
  await copy(PLAIN);
  await clear('text');
  await page.evaluate(() => {
    window.__ev = [];
    window.__src = '';
    const el = document.getElementById('text');
    el.addEventListener('paste', (e) => window.__ev.push([window.__src, 'paste', '', e.isTrusted]));
    el.addEventListener('input', (e) => window.__ev.push([window.__src, 'input', e.inputType, e.isTrusted]));
  });
  const source = (s) => page.evaluate((v) => { window.__src = v; }, s);
  await source('Ctrl+V');
  await pressPaste('text');
  await source('typing');
  await clear('text');
  await page.click('#text');
  for (const c of 'xy') await page.keyboard.sendCharacter(c);
  await source('insertText');
  await page.evaluate(() => {
    const el = document.getElementById('text');
    el.value = ''; el.focus();
    document.execCommand('insertText', false, 'zz');
  });
  const before = await page.evaluate(() => window.__ev.length);
  await source('el.value =');
  await page.evaluate(() => { document.getElementById('text').value = 'assigned'; });
  await wait(200);
  for (const [src, t, it, tr] of await page.evaluate(() => window.__ev)) {
    console.log(src.padEnd(12) + t.padEnd(7) + 'inputType ' + (it || '(none)').padEnd(18) + 'isTrusted ' + tr);
  }
  console.log('el.value =   fired ' + ((await page.evaluate(() => window.__ev.length)) - before) + ' events');
  await clear('text');
  const syn = await page.evaluate((t) => {
    const el = document.getElementById('text');
    const seen = [];
    el.addEventListener('paste', (e) => seen.push({
      isTrusted: e.isTrusted,
      data: e.clipboardData ? e.clipboardData.getData('text/plain') : null,
    }), { once: true });
    const dt = new DataTransfer();
    dt.setData('text/plain', t);
    const notCancelled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
    return { seen, notCancelled, value: el.value };
  }, PLAIN);
  console.log('synthetic    handler ran ' + (syn.seen.length === 1) + '   isTrusted ' + syn.seen[0].isTrusted
    + '   clipboardData "' + syn.seen[0].data + '"');
  console.log('synthetic    dispatchEvent returned ' + syn.notCancelled + '   el.value units ' + syn.value.length);
} else if (mode === 'submit') {
  await copy(MIXED);
  for (const id of ['text', 'email', 'link', 'note']) { await clear(id); await pressPaste(id); }
  // type=email holds a value the browser calls invalid, so the submit is blocked
  // until the form stops reporting. noValidate changes nothing about the values.
  await page.evaluate(() => { document.getElementById('f').noValidate = true; });
  await Promise.all([
    page.waitForNavigation({ waitUntil: 'networkidle2' }),
    page.click('#go'),
  ]);
  console.log((await page.evaluate(() => document.body.innerText)).trim());
}

await browser.close();
rmSync(profile, { recursive: true, force: true });

Start the target and leave it running.

node paste-target.mjs

Steps

  1. Step 1.

    Print the fixtures before anything touches them.

    node paste-probe.mjs fixture
    
    PLAIN                         units  4   U+0061 U+0062 U+0031 U+0032
    MIXED                         units 14   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    TWOLINE                       units  8   U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F
    ASCII14                       units 14   U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041
    EMO8                          units 16   U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE
    ODD                           units 13   U+0041 U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE

    MIXED wraps a CRLF pair in a space, a no-break space and a zero width space at each end. ODD is a letter and six emoji.

  2. Step 2.

    Read the field inside the paste handler, then after it.

    node paste-probe.mjs timing
    
    1 paste handler value         units  0   
    2 paste clipboardData         units  4   U+0061 U+0062 U+0031 U+0032
    microtask value               units  0   
    input insertFromPaste value   units  4   U+0061 U+0062 U+0031 U+0032
    setTimeout 0 value            units  4   U+0061 U+0062 U+0031 U+0032

    This is the line to take away. In the handler the field is empty and clipboardData holds the text. A microtask queued there is still too early. input and a setTimeout of 0 see the new value.

  3. Step 3.

    Compare clipboard, event and field on four controls.

    node paste-probe.mjs arrive
    
    fixture MIXED                 units 14   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    clipboard readText()          units 14   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    type=text clipboardData       units 14   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    type=text pasted el.value     units 13   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+0020 U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    type=text assigned el.value   units 12   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    type=email clipboardData      units 14   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    type=email pasted el.value    units 11   U+00A0 U+200B U+006F U+006E U+0065 U+0020 U+0074 U+0077 U+006F U+200B U+00A0
    type=email assigned el.value  units 10   U+00A0 U+200B U+006F U+006E U+0065 U+0074 U+0077 U+006F U+200B U+00A0
    type=url clipboardData        units 14   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    type=url pasted el.value      units 13   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+0020 U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    type=url assigned el.value    units 10   U+00A0 U+200B U+006F U+006E U+0065 U+0074 U+0077 U+006F U+200B U+00A0
    textarea clipboardData        units 14   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    textarea pasted el.value      units 13   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    textarea assigned el.value    units 13   U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020

    The clipboard returned all 14 units unchanged, and so did clipboardData. The controls then disagree: 13, 11, 13, 13. Assigning the same string gave 12, 10, 10, 13.

  4. Step 4.

    Paste two lines into an input and a textarea.

    node paste-probe.mjs newline
    
    fixture TWOLINE               units  8   U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F
    clipboard readText()          units  8   U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F
    type=text clipboardData       units  8   U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F
    type=text el.value            units  7   U+006F U+006E U+0065 U+0020 U+0074 U+0077 U+006F
    textarea clipboardData        units  8   U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F
    textarea el.value             units  7   U+006F U+006E U+0065 U+000A U+0074 U+0077 U+006F

    Eight units in, seven out of both. The input replaced U+000D U+000A with one U+0020. The textarea replaced the pair with one U+000A and kept the break.

  5. Step 5.

    Paste past maxlength="10" with ASCII and with emoji.

    node paste-probe.mjs cap
    
    ASCII14 pasted units 14   kept units 10   points 10   wellFormed true
          kept U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041
    EMO8    pasted units 16   kept units 10   points  5   wellFormed true
          kept U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE
    ODD     pasted units 13   kept units  9   points  5   wellFormed true
          kept U+0041 U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE U+D83E U+DDFE

    14 ASCII characters gave 10 units, and eight emoji gave 10 units and 5 code points. The odd string gave 9, because the paste stopped rather than cut an emoji in half.

  6. Step 6.

    Cancel the paste, then try the other ways in.

    node paste-probe.mjs blocked
    
    paste events seen       1
    after Ctrl+V                  units  0   
    after typing                  units  4   U+0061 U+0062 U+0031 U+0032
    execCommand insertText  returned true
    after insertText              units  4   U+0061 U+0062 U+0031 U+0032
    execCommand paste       returned false
    after execCommand paste       units  0   

    The handler ran once and the field stayed empty, so Ctrl+V is blocked. Typing filled it, and so did document.execCommand("insertText"), which needs no permission. document.execCommand("paste") returned false.

  7. Step 7.

    Tell a paste from typing, and from a dispatched event.

    node paste-probe.mjs detect
    
    Ctrl+V      paste  inputType (none)            isTrusted true
    Ctrl+V      input  inputType insertFromPaste   isTrusted true
    typing      input  inputType insertText        isTrusted true
    typing      input  inputType insertText        isTrusted true
    insertText  input  inputType insertText        isTrusted true
    el.value =   fired 0 events
    synthetic    handler ran true   isTrusted false   clipboardData "ab12"
    synthetic    dispatchEvent returned true   el.value units 0

    Ctrl+V produced a paste event and an input event with inputType insertFromPaste. Typing and insertText produced insertText and no paste event. Setting el.value produced nothing, and the dispatched ClipboardEvent left the field empty.

  8. Step 8.

    Paste into all four controls and submit the form.

    node paste-probe.mjs submit
    
    text    units 13  bytes 19  U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+0020 U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    email   units 11  bytes 17  U+00A0 U+200B U+006F U+006E U+0065 U+0020 U+0074 U+0077 U+006F U+200B U+00A0
    link    units 13  bytes 19  U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+0020 U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    note    units 14  bytes 20  U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    capped  units  0  bytes  0  
    blocked units  0  bytes  0

    Three of the four match step 3. The textarea does not: its value held 13 units ending U+000A, and 14 with U+000D U+000A went on the wire.

  9. Step 9.

    Send the copied string with no browser in the path.

    curl -s -X POST http://127.0.0.1:8933/save --data 'text=%20%C2%A0%E2%80%8Bone%0D%0Atwo%E2%80%8B%C2%A0%20' --data 'email=%20%C2%A0%E2%80%8Bone%0D%0Atwo%E2%80%8B%C2%A0%20'
    
    text    units 14  bytes 20  U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020
    email   units 14  bytes 20  U+0020 U+00A0 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+00A0 U+0020

    All 14 units on both fields, CRLF included, and the email field keeps the padding removed in step 8. The general case is client validation on the server.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | el.value is empty inside the paste handler | The default action has not run yet | Read the value in the input event, or from a setTimeout of 0. Step 2 shows a microtask is too early. | | clipboardData and el.value differ | The control edited the text on the way in | Print both as U+ columns per control. Step 3 is the reproduction. | | A pasted CRLF shows up as U+0020 | A single line control replaced the break with a space | Assert on what the field holds, not on what was copied. A textarea keeps U+000A instead. | | type=url kept the padding and type=email did not | Sanitizing on the paste path is per type, and is not the assignment path | Test the path your users take. Step 3 measured a 3 unit gap on the same string. | | A field capped at 10 holds 9 units | The paste stopped rather than split a surrogate pair | Treat the cap as an upper bound and assert on el.value.length, never on the length you pasted. | | Ctrl+V leaves the field empty and insertText fills it | preventDefault cancels the paste and nothing else | File a paste block as friction, not as a control. | | The textarea sent U+000D U+000A after holding U+000A | Form serialization restores the pair | Count line endings on the server. Steps 8 and 9 hold both numbers. |

Common mistakes

Sign: A paste rule validates the value the field had before the paste, so the first paste always passes and the second reports the first.Cause: The paste event fires before the text is inserted. Step 2 read el.value in the handler and got 0 characters while clipboardData held 4, and a microtask queued from that handler still got 0. Read the value in the input event, where step 7 shows inputType insertFromPaste, or from a setTimeout of 0.
Sign: An automated test dispatches a ClipboardEvent, the handler runs, and the field stays empty.Cause: Step 7 dispatched one with a DataTransfer attached. The handler ran and read the text, isTrusted was false, and el.value stayed at 0 units, because an untrusted event carries no default action. The test proves the handler is wired up and proves nothing about what the field receives.
Sign: A fix is verified by assigning the value in the console, and the same defect comes back from a real user.Cause: Step 3 put one 14 unit string into type=url two ways. The paste kept 13 units with the leading space intact, the assignment kept 10 with the padding gone. type=email removed the padding on both paths. One control, two paths, a 3 unit difference, and only the paste path is what a person does.
Sign: Paste is blocked on a password confirmation field and the ticket is closed as hardened.Cause: Step 6 measured what the block achieves: Ctrl+V left the field empty. It also measured what it does not. Typing filled the field, and document.execCommand insertText filled it with the same four characters, from page script, with no clipboard permission. The block removes one keystroke and leaves typing and script insertion untouched. Whether a context menu paste survives it was not measured here.

Thresholds

a paste into maxlength=10 kept 10 units of a 14 unit ASCII string, 10 units of a 16 unit emoji string, and 9 units of a 13 unit string that would have needed a surrogate pair split at unit 10 Source: Measured in step 5, Chrome 152.0.7977.76, 2026-09-12, against https://html.spec.whatwg.org/multipage/input.html#attr-input-maxlength
one 14 unit paste left 13 units in type=text, 11 in type=email, 13 in type=url and 13 in a textarea, while assigning the same string to el.value left 12, 10, 10 and 13 Source: Measured in step 3, Chrome 152.0.7977.76, 2026-09-12, against https://html.spec.whatwg.org/multipage/input.html#value-sanitization-algorithm

What to check next

FAQ

How do I detect a paste event in javascript?

Listen for paste, or for input and read e.inputType. Step 7 measured insertFromPaste for Ctrl+V and insertText for typing. el.value fired neither.

How do I check for copy and paste?

Drive a real Ctrl+V and read the field in the input event. A counter says a paste happened, not what the field kept.

Why is the input empty in my paste handler?

The handler runs before the insertion. Step 2 read 0 characters there and 4 in event.clipboardData.getData("text/plain"). A microtask is still too early.

Can I stop someone pasting into a field?

You can cancel the keystroke. Step 6 blocked Ctrl+V with preventDefault, then filled the same field by typing and with document.execCommand("insertText").

Does maxlength apply to a paste?

Yes, in UTF-16 code units. Step 5 pasted 14 ASCII characters into a cap of 10 and kept 10, and 8 emoji and kept 5.

Verified

Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2curl 8.21.0Windows 11 build 22631

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate14 minpublished updated Maks Verny