본문 바로가기
C.W.K.
Stream
Lesson 05 of 07 · published

React-friendly fill input — Native setter trick

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

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"input.value = 'foo' 를 넣으면 화면엔 'foo' 가 보여. 그런데 user가 전송을 누르면 서버엔 빈 문자열이 도착해. 이제 input의 주인은 React 고, 그냥 값을 꽂는 건 React 눈에 안 보이거든. 이 lesson은 현업에서 만날 framework를 거의 다 커버하는 한 줄짜리 요령이야."

순진한 set이 실패하는 이유

React는 HTMLInputElementHTMLTextAreaElement의 prototype을 갈아 끼워서, 자기가 만든 setter로 값 변화를 따라가. 그래서 input.value = 'foo'를 쓰면, 그 setter가 '이건 React 바깥에서 들어온 변화네' 하고 표시해 두고 변경 목록에서 조용히 빼 버려. 눈에 보이는 값은 바뀌어 (속성 자체는 움직이니까). 하지만 React의 속마음은 그대로라서, 다음번에 다시 그릴 때 원래대로 돌아가 버려.

Vue도 Svelte도 Solid도 모양만 조금 다를 뿐 비슷한 장치를 갖고 있어. 그래서 해법도 같아. framework가 씌워 놓은 껍데기를 건너뛰고 원래 setter에 직접 쓰는 거야.

Native setter trick

framework가 덮어쓰기 전의 원본 setter를 prototype에서 꺼내 오는 거야:

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

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

여기서 두 가지가 일어나:

  1. 원본 setter가 값을 진짜로 써. 감시하는 껍데기를 안 거치니까 DOM이 조용히 갱신돼.
  2. input event를 직접 쏴 줘. React도 다른 framework도 속마음을 갱신할 때 원래의 input event를 듣거든. 이때 bubbles가 중요해. 대부분의 framework는 listener를 input 자체가 아니라 뿌리 쪽에 걸어 두니까.

이 둘을 같이 하면 실제로 키를 친 것과 구별이 안 돼. React의 onChange가 돌고, 속마음이 갱신되고, 다음에 그릴 때 새 값이 나와. 전송도 제대로 되고.

Textarea와 contenteditable

<textarea>HTMLTextAreaElement.prototype 사용:

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

contenteditable 요소 (Slack, Notion, Gmail의 compose) 엔 trick이 다름:

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

document.execCommand는 이미 폐기 예정이라고 붙어 있는데도 아직 모든 브라우저에서 돌아가. Selection API로 대신하는 방법들 (navigator.clipboard.write 후 붙여넣기, 아니면 InputEvent를 직접 쏘기)은 더 복잡한 데다 framework 마다 결과가 달라. ClipDeck 한테는 execCommand가 현실적인 선택이야.

이게 중요할 때

ClipDeck v1은 입력창을 안 채워. clip은 꺼내 읽는 거지 밀어 넣는 게 아니거든. v2 계획표에 '지금 커서가 있는 입력창에 이 clip 붙이기' 가 올라와 있어. 조각을 모아 뒀다가 편집기나 채팅 앱에 붙여 쓰는 흐름을 위한 거야. 바로 그때 이 요령이 필요해져. 이걸 안 쓰면 Slack 이나 Gmail에 붙였을 때 화면엔 글자가 보이는데 실제로 전송되는 메시지는 텅 비어 있거든.

감지 — 이게 framework-managed 인가?

빠르게 보려면 input의 __reactProps__reactInternalInstance를 읽어 봐 (React 내부 항목이라 이름은 버전마다 조금씩 달라). 그게 있으면 React 야. Vue는 요소에 __vueParentComponent를 달아 두고. Svelte는 눈에 띄는 표식이 없는데, 대신 input이 원래 event에 얌전히 반응해서 굳이 알아내지 않아도 이 요령이 그냥 통해.

직접 떠볼 수도 있어. 그냥 값을 꽂아 보고 한 박자 뒤에 다시 읽는 거야. 되돌아가 있으면 framework가 쥐고 있는 거지. 근데 실전에선 그냥 항상 원본 setter를 써. 어느 쪽이든 맞는 답이거든.

모두 cover 하는 한 helper

Wrap:

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);
  }
}

갈래는 셋인데 부르는 쪽은 하나야. React 든 Vue 든 Svelte 든 Solid 든 통하고, contenteditable을 쓰는 편집기도 대부분 받아 줘.

Framework는 상태를 따라가려고 input의 setter를 갈아 끼워 둬. 그래서 input.value = ...를 그냥 쓰면 감시망을 조용히 비껴가 버려. 해법은 한 줄이야 — prototype에서 원본 setter를 꺼내 부르고, bubbles를 켠 input event를 쏴. 그러면 진짜로 키를 친 것과 구별이 안 돼.
넣은 건 반드시 보여 줘. user가 뭐가 들어갔는지 보지도 못한 채 붙여 넣고 전송하는 건 무서운 자동화고, Chrome Web Store 심사자한테는 걸리는 지뢰야. 넣은 글자는 항상 입력창에 그려 주고, 전송 전에 물러날 틈을 줘. ClipDeck v2는 붙여 넣는 동작마다 Lesson 6의 미리보기·확인 창을 띄울 거야.

Code

content.js — 세 input shape 모두 cover 하는 fillInput 도구·javascript
// content.js — input / textarea / contenteditable cover 하는 fillInput 도구
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 — focus 된 input 에 clip paste 위해 도구 호출
async function pasteClipIntoFocused(clipText) {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  // Content script 에서 현재-focus 요소의 selector 얻기
  const [{ result: focusedSelector }] = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: () => {
      const el = document.activeElement;
      if (!el || el === document.body) return null;
      // 순진: 가능하면 id selector, 아니면 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 — robust round-trip 위한 더 단단한 active-element selector·javascript
// content.js — bonus: active element 의 더 robust selector
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

첫 번째 code block (CD_TOOLS.fillInput)을 clipdeck/content.js에 넣어. 그다음 텍스트 입력창이 있는 React 사이트를 아무거나 열어 (https://react.dev의 playground 나 Twitter/X 글쓰기 창도 좋아). content script 쪽 DevTools console에서 입력창을 하나 잡고 — document.querySelector('input[type="text"]') 같은 식으로 — 그 요소의 selector를 넘겨서 CD_TOOLS.fillInput을 직접 불러 봐. 두 가지를 확인해. 하나는 입력창에 글자가 실제로 보이는 것, 다른 하나는 tab을 누르거나 다른 데를 눌렀을 때 framework가 이걸 진짜 편집으로 받아들이는 것 (글자 수가 갱신된다든지, 검증 아이콘이 바뀐다든지). 그러고 나서 그냥 값을 꽂는 것과 비교해 봐. input.value = 'foo' 를 직접 해 보면 글자는 뜨는데 framework는 못 본 척해. 그 차이를 만드는 게 원본 setter 요령이야.
Hint
contenteditable 갈래가 아무 반응이 없으면, 그 요소가 실은 contenteditable이 아닐 수 있어 (slate 나 ProseMirror 처럼 InputEvent를 따로 가로채는 복잡한 편집기를 쓰는 경우가 있거든). 그런 건 그 편집기를 알아야 다룰 수 있어서 ClipDeck v1이 감당할 범위를 한참 넘어. selector가 SW를 한 바퀴 돌고 와서 null이 되면, 찾은 시점이랑 fillInput이 도는 시점 사이에 커서가 옮겨 간 거야. popup이 열릴 때 흔하지. 그럴 땐 흐름 전체를 content script 쪽 handler로 옮겨서 커서가 있는 요소를 그 자리에서 바로 잡게 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.