발행일

편집 중 트리를 누르면 입력이 사라졌다 — 되돌린 선택이 다시 화면을 지우는 문제

편집 중 트리를 누르면 입력이 사라졌다

인스턴스 관리 화면은 왼쪽에 클래스 트리, 오른쪽에 상세/편집 폼이 있는 구조입니다. 트리에서 노드를 고르면 오른쪽이 그 노드 내용으로 갈아 끼워져요.

$tree.on("select_node.jstree", function (e, data) {
  const id = data.node.id;
  if (instItems[id]) renderInstance(instItems[id]);
  else if (classItems[id]) renderClass(classItems[id], id);
  else renderEmpty("이 네임스페이스에서 클래스를 선택하세요.", "fa-cube");
  ...
});

문제는 편집 중에도 똑같이 갈아 끼운다는 것이었습니다. 슬롯 열 개를 채우다가 트리를 잘못 누르면 아무 경고 없이 전부 사라집니다. 용어·클래스 화면에는 이미 이탈 가드가 있었는데 인스턴스 화면만 빠져 있었어요.

되묻는 것까지는 5줄, 되돌리는 게 문제였다

confirm()으로 되묻는 것 자체는 간단합니다. 어려운 건 사용자가 "취소"를 눌렀을 때 였어요.

취소를 누르면 트리 선택을 원래 노드로 되돌려야 합니다. 그런데 select_node()를 부르면 그게 또 select_node.jstree 이벤트를 쏩니다. 그 핸들러가 다시 renderInstance()를 불러서, 지키려던 입력을 결국 지워버립니다. 되돌리는 동작이 되돌리려던 것을 파괴하는 구조였습니다.

방법장점포기하는 것판단
renderXxx() 호출부에 "편집 중이면 건너뛰기" 조건이벤트는 그대로 흐름렌더 함수가 셋인데다 앞으로 늘어남. 하나 빠뜨리면 그 경로로만 입력이 날아가는, 재현 어려운 버그가 됨기각
② 편집 중엔 트리를 통째로 잠금(disable_node)애초에 못 누름다른 노드 보기도 막힘. 편집 중 참고하러 옆 노드를 보는 건 정상 사용임기각
deselect_nodeselect_node, 그 구간만 플래그로 이벤트 무시되돌림이 화면을 안 건드림. 무시 구간이 두 줄로 눈에 보임전역 플래그 하나가 늘어남. 플래그 해제를 빠뜨리면 트리가 통째로 먹통채택
beforeunload식으로 jstree의 changed.jstree 전 단계에서 취소되돌림 자체가 필요 없음jstree는 선택을 취소할 수 있는 전(before) 훅이 없음. select_node.jstree는 이미 선택된 뒤에 온다불가

③으로 갔습니다. 핸들러 맨 앞에서 통째로 빠져나가는 형태입니다.

// 트리에서 지금 보고 있는 노드 — 편집 이탈을 되돌릴 때 여기로 선택을 복구한다.
let selectedNodeId = null;
// 되돌리기로 다시 고르는 선택은 통째로 무시한다 — 다시 렌더하면 지키려던 입력이 날아간다.
let suppressSelectEvent = false;
const LEAVE_FORM_MESSAGE = "입력 중인 내용은 저장되지 않습니다. 이동할까요?";

function isEditingForm() {
  return mode === "edit" || mode === "add";
}
$tree.on("select_node.jstree", function (e, data) {
  if (suppressSelectEvent) return;
  const id = data.node.id;
  // 편집·추가 중 다른 노드를 고르면 상세가 갈아 끼워져 입력이 사라진다 — 용어·클래스 화면과
  // 같은 가드다. 되물으면 트리 선택을 보던 노드로 되돌린다.
  // data.event 는 사용자가 노드를 직접 눌렀을 때만 실린다. 저장 직후 refresh_node 가 옛 선택을
  // 복원하거나 안내 링크가 옮겨 주는 프로그램 선택에는 없어, 그런 이동까지 되묻지 않는다.
  if (data.event && isEditingForm() && id !== selectedNodeId) {
    if (!confirm(LEAVE_FORM_MESSAGE)) {
      const tree = $tree.jstree(true);
      suppressSelectEvent = true;
      tree.deselect_node(id);
      if (selectedNodeId) tree.select_node(selectedNodeId);
      suppressSelectEvent = false;
      return;
    }
  }
  selectedNodeId = id;
  ...
});

data.event가 있느냐 없느냐

이 가드를 붙이자마자 두 번째 문제가 나왔습니다. 저장 직후에도 되묻기 시작한 겁니다.

저장하면 refresh_node()로 트리를 갱신하는데, jstree가 갱신 후 옛 선택을 복원하면서 select_node.jstree를 쏩니다. 그러면 아직 modeedit인 시점에 이벤트가 와서, 방금 저장한 사용자에게 "저장 안 됩니다, 이동할까요?"라고 묻습니다. 반대 의미의 경고예요.

"0건 안내" 링크로 다른 클래스로 보내주는 경로도 마찬가지였습니다. 사용자는 이미 링크를 눌러 동의했는데 한 번 더 묻습니다.

구분점은 data.event였습니다. jstree는 사용자가 노드를 직접 눌렀을 때만 원본 DOM 이벤트를 data.event로 실어 줍니다. 코드가 select_node()를 부른 프로그램 선택에는 없어요.

if (data.event && isEditingForm() && id !== selectedNodeId) {

mode만 봐서는 "사용자가 떠나려는 것"과 "코드가 화면을 정리하는 것"을 구별할 수 없었습니다. 라이브러리가 이미 구별해 주고 있었고, 저는 그 필드를 안 보고 있었어요.

같은 뿌리 — refresh()가 비동기다

며칠 뒤 같은 화면의 e2e 스펙 2건이 간헐적으로 빨개졌습니다. 4번 돌리면 1~2번 실패하는 식이라 처음엔 타이밍 이슈로 보고 waitForTimeout을 넣을 뻔했습니다.

async function openAllNamespaces(page) {
  await page.goto(INSTANCE_URL);
  await page.locator("#nsScopeToggle").click();   // ← 여기서 refresh() 가 돈다
}
// ... 그리고 곧바로
await page.evaluate((id) => window.jQuery("#instanceTree").jstree(true).open_node(id), nsNode);

네임스페이스 스코프 토글의 change 핸들러가 jstree refresh()를 부릅니다. 이게 비동기입니다. 트리가 다시 그려지기 전에 open_node()를 부르면, 예외도 로그도 없이 조용히 무시됩니다. 그 다음 클래스 앵커를 찾는 단계에서야 "없다"고 죽으니, 실패 지점과 원인 지점이 떨어져 있었어요.

앵커가 다시 그려지길 기다린 뒤 펼치도록 분리했습니다.

// 클래스 노드는 네임스페이스를 펼쳐야 붙는다(lazy). 스코프 토글이 트리를 refresh 하므로
// 네임스페이스 앵커가 다시 그려진 뒤에 펼친다 — 그 전에 부르면 조용히 무시된다.
async function openAddForm(page, namespaceNode, classNode) {
  await page.locator(`#instanceTree a[id="${namespaceNode}_anchor"]`).waitFor();
  await page.evaluate(
    (id) => window.jQuery("#instanceTree").jstree(true).open_node(id),
    namespaceNode,
  );
  const classAnchor = page.locator(`#instanceTree a[id="${classNode}_anchor"]`);
  await classAnchor.waitFor();
  await classAnchor.click();
  await page.locator("#drAddBtn").click();
}

waitForTimeout(500)을 안 쓴 이유는 단순합니다. 그건 "얼마나 걸리는지 모르겠다"는 말이라 CI가 느린 날 다시 빨개져요. waitFor()무엇을 기다리는지를 적습니다.

이미 다른 스펙(instance-empty-object-slot)에 같은 관용구가 있었는데, 그때는 "이 스펙만의 사정"으로 보고 헬퍼로 안 뺐습니다. 두 번째로 같은 걸 만나고 나서야 뺐어요.

버그 수정에서도 e2e 수정에서도 같은 문장이 나옵니다. 트리 위젯에서 "지금 상태"를 읽고 곧바로 무언가 하는 코드는 거의 다 틀립니다. 렌더가 끝난 시점은 이벤트나 대기로만 알 수 있어요.

검증

  • 이탈 가드 회귀 스펙 신설(instance-edit-leave-guard.spec.js, 107줄) — 편집 중 다른 노드 클릭 → 확인 문구 노출, 취소 시 입력값 보존 + 트리 선택 원위치, 확인 시 이동.
  • 저장 직후 refresh_node 경로에서 확인창이 뜨지 않는 것을 별도 케이스로 못박음. 이게 없으면 다음 사람이 data.event 조건을 "불필요한 방어"로 보고 지웁니다.
  • 레이스 스펙 2건 4회 연속 통과(고치기 전에는 4회 중 절반 실패), ESLint 신규 0건.
  • 문구는 용어·클래스 화면의 기존 이탈 가드와 동일하게 맞춤 — 같은 성격의 경고가 화면마다 다른 문장이면 사용자가 다른 일로 읽습니다.

남은 것 · 한계

  • confirm()을 씁니다. 네이티브 모달이라 스타일이 화면과 따로 놀고, 문구에 강조도 못 넣어요. 이 화면군 셋이 다 같은 방식이라 일단 맞췄지만, 공용 확인 모달로 바꾸는 게 맞습니다. 그때는 세 화면을 같이 옮겨야 합니다.
  • suppressSelectEvent는 전역 플래그입니다. 지금은 세우고 지우는 구간이 세 줄 안에 있어 안전하지만, 되돌림 도중에 비동기가 끼어들면 플래그가 켜진 채 남아 트리가 통째로 먹통이 됩니다. try/finally로 감싸는 게 최소한의 방어인데 안 해뒀어요. 다음 수정 때 넣을 자리입니다.
  • 입력이 실제로 바뀌었는지는 안 봅니다. 편집 모드로 들어갔다가 아무것도 안 고치고 나가도 되묻습니다. dirty 판정을 붙이면 확인창이 눈에 띄게 줄 텐데, 슬롯 값이 리터럴·객체·데이터유형 세 종류라 비교 대상을 정하는 게 간단치 않아 미뤘습니다.

관련 글: Playwright로 메뉴 136종 QA 스윕 · 표준데이터를 고정 컬럼에서 인스턴스 기반 모델로