C.W.K.
Stream
Lesson 05 of 07 · published

React-Friendly Fill Input — The Native Setter Trick

~11 min · react, fill-input, synthetic-events, framework

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"You set input.value = 'foo'. The UI shows 'foo'. The user clicks submit and the server gets an empty string. React owns the input now, and your naive set is invisible to its tracker. Lesson 5 is the one-line trick that fixes it on every framework you'll meet in production."

Why the Naive Set Fails

React patches the prototype of HTMLInputElement and HTMLTextAreaElement to track value changes via a custom setter. When you write input.value = 'foo', the patched setter notes that the change came from outside React's component lifecycle and silently discards it from the change tracker. The visible value updates (the underlying property still moves), but React's internal state stays whatever it was, and the next render snaps it back.

Vue, Svelte, Solid have similar mechanisms with slightly different shapes. The fix is the same: bypass whatever wrapper the framework installed and write directly to the native setter.

The Native Setter Trick

Get the original setter from the prototype before any framework has had a chance to overwrite it:

const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
  window.HTMLInputElement.prototype,
  'value'
).set;

nativeInputValueSetter.call(input, 'foo');
input.dispatchEvent(new Event('input', { bubbles: true }));

Two things happen here:

  1. The native setter writes the actual property. No tracker wrap; the DOM updates with no fanfare.
  2. The synthetic input event fires. React (and every other framework) listens for native input events to update internal state. Bubbling matters because most frameworks attach the listener up at the root, not on the input itself.

Together they look indistinguishable from a real keystroke. React's onChange runs, internal state updates, the next render renders the new value. Submit works.

Textareas and ContentEditable

For <textarea>, use HTMLTextAreaElement.prototype:

const nativeTextareaValueSetter = Object.getOwnPropertyDescriptor(
  window.HTMLTextAreaElement.prototype,
  'value'
).set;

For contenteditable elements (Slack, Notion, Gmail's compose), the trick is different:

el.focus();
document.execCommand('insertText', false, 'foo');

document.execCommand is deprecated but still works in every browser. The Selection-API-based replacement (navigator.clipboard.write + paste, or InputEvent dispatch) is more involved and varies by framework. For ClipDeck, execCommand is the pragmatic choice.

When This Matters

ClipDeck v1 doesn't fill inputs — clips are read-out, not paste-in. v2's roadmap has 'paste this clip into the focused input' for the workflow where a user collects snippets and pastes them into their text editor or chat app. That's exactly when the native setter trick matters; without it, pasting into Slack or Gmail leaves the UI showing the text but the actual sent message empty.

Detection — Is This Framework-Managed?

A quick check: read the input's __reactProps or __reactInternalInstance (React's internal field; the exact name has varied across React versions). If present, the framework is React. Vue uses __vueParentComponent on the element. Svelte has no obvious marker but its inputs respond to native events normally, so the trick still works without detection.

You can also feature-detect: try the naive set, then read the value back after a tick. If it reverts, the framework owns it. In practice, just always use the native setter — it's correct in both cases.

One Helper to Cover Them All

Wrap it:

function fillInput(el, value) {
  if (el.tagName === 'INPUT') {
    const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
    setter.call(el, value);
    el.dispatchEvent(new Event('input', { bubbles: true }));
  } else if (el.tagName === 'TEXTAREA') {
    const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
    setter.call(el, value);
    el.dispatchEvent(new Event('input', { bubbles: true }));
  } else if (el.isContentEditable) {
    el.focus();
    document.execCommand('insertText', false, value);
  }
}

Three branches, one consistent caller. Works across React, Vue, Svelte, Solid, and most contenteditable rich editors.

Frameworks patch input setters to track state. Naive input.value = ... bypasses the tracker silently. The fix is one line: pull the native setter from the prototype, call it, dispatch a bubbling input event. Indistinguishable from a real keystroke.
Always show what you typed. An invisible fill — paste-then-submit without the user seeing the typed value — is the kind of automation that scares users and tripwires Chrome Web Store reviewers. Always render the inserted text in the input AND give the user a chance to back out before any submit. ClipDeck v2 will surface a preview-and-confirm overlay (Lesson 6) for every paste action.

Code

content.js — fillInput tool covering all three input shapes·javascript
// content.js — fillInput tool covering input, textarea, contenteditable
CD_TOOLS.fillInput = async ({ selector, value }) => {
  const el = document.querySelector(selector);
  if (!el) return { ok: false, reason: "no-element" };
  if (el.tagName === "INPUT") {
    const setter = Object.getOwnPropertyDescriptor(
      window.HTMLInputElement.prototype,
      "value"
    ).set;
    setter.call(el, value);
    el.dispatchEvent(new Event("input", { bubbles: true }));
    return { ok: true };
  }
  if (el.tagName === "TEXTAREA") {
    const setter = Object.getOwnPropertyDescriptor(
      window.HTMLTextAreaElement.prototype,
      "value"
    ).set;
    setter.call(el, value);
    el.dispatchEvent(new Event("input", { bubbles: true }));
    return { ok: true };
  }
  if (el.isContentEditable) {
    el.focus();
    const inserted = document.execCommand("insertText", false, value);
    return { ok: inserted };
  }
  return { ok: false, reason: "not-an-input" };
};
background.js — paste-clip-into-focused-input flow·javascript
// background.js or popup.js — call the tool to paste a clip into the focused input
async function pasteClipIntoFocused(clipText) {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  // Get a selector for the currently-focused element from the content script
  const [{ result: focusedSelector }] = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: () => {
      const el = document.activeElement;
      if (!el || el === document.body) return null;
      // Naive: id selector if available, otherwise tagname index.
      if (el.id) return `#${CSS.escape(el.id)}`;
      const tag = el.tagName.toLowerCase();
      const all = Array.from(document.querySelectorAll(tag));
      return `${tag}:nth-of-type(${all.indexOf(el) + 1})`;
    },
  });
  if (!focusedSelector) return;
  await chrome.tabs.sendMessage(tab.id, {
    type: "tool",
    name: "fillInput",
    args: { selector: focusedSelector, value: clipText },
  });
}
content.js — sturdier active-element selector for robust round-trip·javascript
// content.js — bonus: a more robust selector for the active element
function generateSelector(el) {
  if (el.id) return `#${CSS.escape(el.id)}`;
  const parts = [];
  let node = el;
  while (node && node.nodeType === 1 && node !== document.body) {
    const tag = node.tagName.toLowerCase();
    const sibs = node.parentElement
      ? Array.from(node.parentElement.children).filter((s) => s.tagName === node.tagName)
      : [node];
    const idx = sibs.indexOf(node) + 1;
    parts.unshift(`${tag}:nth-of-type(${idx})`);
    node = node.parentElement;
  }
  return parts.join(" > ");
}

External links

Exercise

Add the first code block (CD_TOOLS.fillInput) to clipdeck/content.js. Open any React-based site with text inputs (try https://react.dev's playground or Twitter/X's compose). In the content script's DevTools console, query for an input — document.querySelector('input[type="text"]') for instance — then call CD_TOOLS.fillInput directly with that element's selector. Confirm two things: the input visually shows your text, AND when you tab away or click elsewhere the framework treats it as a real edit (e.g., the character count under a tweet updates, the validation icon flips, etc.). Compare to the naive set: try input.value = 'foo' directly — you'll see the value appear but the framework ignores it. The native setter trick is what makes the difference.
Hint
If the contenteditable branch silently does nothing, the element may not be truly contenteditable (some frameworks use complex slate/ProseMirror editors that intercept InputEvent specifically). For those, you'd need framework-specific knowledge — way past ClipDeck v1 scope. If your selector returns null in the SW round-trip, the active element changed between when you queried and when fillInput ran — common when popups open. Move the entire flow into a content-script-side handler so the active element is captured synchronously.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.