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
- Node 22 and curl, for the files below. Confirm
127.0.0.1:8933is free first. - Chrome and
puppeteer-core, withCHROMEset to your binary. This run drove Chrome 152.0.7977.76 with--lang=en-US, once. - Headless Chrome keeps its own clipboard: a marker in the Windows clipboard survived a full run untouched.
navigator.clipboardanswers only onceclipboardReadWriteis granted over CDP. The paste is a real Ctrl+V, not a dispatched event.- Background: the value sanitization algorithm and Element: paste event.
- Fixtures are escapes, read back as
U+columns over UTF-16 code units.
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
- Step 1.
Print the fixtures before anything touches them.
node paste-probe.mjs fixturePLAIN 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+DDFEMIXEDwraps a CRLF pair in a space, a no-break space and a zero width space at each end.ODDis a letter and six emoji. - Step 2.
Read the field inside the paste handler, then after it.
node paste-probe.mjs timing1 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+0032This is the line to take away. In the handler the field is empty and
clipboardDataholds the text. A microtask queued there is still too early.inputand asetTimeoutof 0 see the new value. - Step 3.
Compare clipboard, event and field on four controls.
node paste-probe.mjs arrivefixture 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+0020The 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. - Step 4.
Paste two lines into an input and a textarea.
node paste-probe.mjs newlinefixture 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+006FEight 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.
- Step 5.
Paste past
maxlength="10"with ASCII and with emoji.node paste-probe.mjs capASCII14 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+DDFE14 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.
- Step 6.
Cancel the paste, then try the other ways in.
node paste-probe.mjs blockedpaste 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 0The 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. - Step 7.
Tell a paste from typing, and from a dispatched event.
node paste-probe.mjs detectCtrl+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 0Ctrl+V produced a
pasteevent and aninputevent withinputTypeinsertFromPaste. Typing andinsertTextproducedinsertTextand nopasteevent. Settingel.valueproduced nothing, and the dispatchedClipboardEventleft the field empty. - Step 8.
Paste into all four controls and submit the form.
node paste-probe.mjs submittext 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 0Three of the four match step 3. The textarea does not: its
valueheld 13 units ending U+000A, and 14 with U+000D U+000A went on the wire. - 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+0020All 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
Thresholds
What to check next
- How to test whitespace trimming in a form field: the assignment path step 3 contradicts.
- How to check field length limits in a form: the rest of the
maxlengthcontract. - How to test that client validation is enforced on the server: what step 9 sends past the controls.
- How to check required field validation: the attribute a pasted blank answers to.
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.
Related on this site
intermediate14 minpublished updated Maks Verny