# JavaScript 훅

서드파티 JS 라이브러리(Chart.js, Mapbox, CodeMirror 등)를 컴포넌트에 붙이는 장치다. Phoenix
LiveView의 훅과 같은 모양이다.

## 빠른 시작

### 1. 훅을 정의한다

```javascript
window.wireview.hooks.ChartHook = {
  mounted() {
    // 엘리먼트가 페이지에 들어왔을 때
    const config = JSON.parse(this.el.dataset.config);
    this.chart = new Chart(this.el, config);
  },

  updated() {
    // DOM morph가 끝난 뒤
    this.chart.update();
  },

  destroyed() {
    // 엘리먼트가 사라질 때
    this.chart.destroy();
  }
};
```

### 2. 템플릿에서 쓴다

```html
<div wire-hook="ChartHook" data-config='{"type": "line", "data": {...}}'>
</div>
```

## 훅 파일을 어디에 두나

**앱의 static 아래 `hooks/`에 둔다.** 프로젝트가 `<script>` 태그를 쓸 필요가 없다.

```
myapp/
└── static/
    └── myapp/          ← 앱 라벨 (Django의 정적 파일 네임스페이스)
        └── hooks/      ← wireview의 규약
            └── chart.js
```

`{% wireview_header %}`가 설치된 앱 전부에서 이 디렉터리를 찾아 `defer`로 싣는다. 파일은
자기 훅 이름을 자기가 쓴다.

```javascript
// myapp/static/myapp/hooks/chart.js
window.wireview.hooks.Chart = {
  mounted() { ... },
};
```

### 왜 페이지마다 전부 싣나

수집은 **페이지가 아니라 프로젝트 단위**다. 그 페이지가 쓰는 훅만 실으면 boost 내비게이션에서
깨진다 — 경계 안에서 이동할 때 wireview는 body만 갈아 끼우므로 **목적지 문서의 `<head>`에 있던
`<script>`는 실행되지 않는다.** 첫 로드에서는 되고 이동해서 들어가면 안 되는 결함이 된다.

부수 효과로 수집이 요청과 무관해진다 — 기동 때 한 번 정해진다.

### 순서는 보장된다

`defer` 스크립트는 문서 순서대로 실행되므로 훅 파일이 도는 시점에 `window.wireview`는 이미 있다.
그리고 **wireview는 `defer` 스크립트가 전부 실행된 뒤에야 컴포넌트를 join한다** — 훅 파일이 늦게
도착해도 첫 렌더 때 훅이 자리에 있다는 뜻이다.

이 보장이 필요한 이유는 실패가 조용하기 때문이다. 등록되지 않은 훅은 오류를 내지 않고 콘솔 경고
한 줄(`Hook "X" not registered`)만 남기며, 컴포넌트는 정상으로 보인다. `manage.py check`의
`wireview.W011`이 그 짝을 먼저 잡는다 — 템플릿이 부르는 이름을 등록하는 파일이 없을 때.

### 직접 싣고 싶다면

번들러가 있는 프로젝트는 수집을 끄고 같은 파일들을 자기 번들에 넣으면 된다.

```python
WIREVIEW = {"COLLECT_HOOKS": False}
```

끄면 `wireview.W011`도 함께 조용해진다 — 비교할 대상이 없기 때문이다. 직접 실을 때도
**번들이 wireview보다 먼저 실행되지 않게** 해야 한다. 인라인 `<script>`는 `defer`가 안 되므로
`window.wireview`가 없는 시점에 돌아 실패한다.

동작하는 예제는 [`examples/hooks/`](https://github.com/itda-work/django-wireview/tree/v1.1.0/examples/hooks)에 있다.

## 훅 수명주기

| 콜백 | 언제 | 쓰임새 |
|------|------|--------|
| `mounted()` | 엘리먼트가 들어오고 첫 렌더가 끝난 뒤 | 라이브러리 초기화 |
| `beforeUpdate()` | DOM morph 직전 (동기) | 스크롤 위치·선택 영역 저장 |
| `updated()` | DOM morph가 끝난 뒤 | 상태 복원, 라이브러리 갱신 |
| `destroyed()` | 엘리먼트가 DOM에서 빠질 때. 컴포넌트가 페이지를 떠날 때(부모가 그리지 않음, boost 이동)도 뿌리에 단 훅까지 모두 | 자원 정리 |
| `disconnected()` | WebSocket이 끊겼을 때 | 오프라인 표시 |
| `reconnected()` | WebSocket이 다시 붙고 컴포넌트가 다시 join할 때. 같은 훅 인스턴스다 | 데이터 새로고침 |
| `navigated()` | boost 이동이 끝난 뒤, 이동 전부터 있었고 이동 뒤에도 남은 훅에게 한 번. 주로 sticky 컴포넌트의 훅 | 새 `<body>`에 페이지 전체 효과 다시 걸기 ([boost](/wireview/reference/boost/#이동을-알기-wireviewnavigated-navigated)) |

훅은 **자기를 감싼 가장 가까운 컴포넌트**의 것이다. 중첩된 컴포넌트 안의 훅은 바깥 컴포넌트가 아니라 안쪽
컴포넌트의 `push_event`를 받는다. 렌더가 엘리먼트를 옮기기만 하면(id가 같은 목록 항목의 순서가 바뀜)
훅은 그대로 살아 있고 `destroyed()`도 `mounted()`도 다시 불리지 않는다.

부모의 렌더가 새로 그린 컴포넌트(LiveComponent, 또는 `{% if %}`가 다시 그린 `{% component %}`)의 훅도 그 렌더가 패치될 때
마운트된다. 그렇게 그려진 `{% component %}`는 그때 페이지가 join한다 — 그 `joined()`가 돌고, 그 안의
`wire-viewport-*`는 그 컴포넌트의 것으로 관찰된다. 그 LiveComponent가 `joined()`에서
보낸 `push_event`는 렌더보다 먼저 패치를 기다리지 않고 도착하지만, 페이지는 그 요소가 들어올 다음 프레임까지
붙들었다가 마운트된 훅에 전한다(전에는 훅이 아예 마운트되지 않았고 이벤트는 아무 데도 닿지 않았다).
이미 화면에 있는 컴포넌트의 핸들러가 렌더로 새 훅을 그리고 같은 핸들러에서 그 훅에 `push_event`를 보내도
마찬가지다 — 그 컴포넌트에 예약된 패치가 있으면 이벤트는 그 패치 뒤에 간다. 부모의 패치가 이미 join된
중첩 컴포넌트(LiveComponent 포함) 안에 그린 훅은 그 중첩 컴포넌트의 것으로 마운트되고, 그 패치가 지나간
중첩 컴포넌트의 훅도 `beforeUpdate()`·`updated()`를 받는다.

## 훅 컨텍스트

콜백 안의 `this`가 주는 것.

| 속성 | 타입 | 뜻 |
|------|------|-----|
| `this.el` | `HTMLElement` | `wire-hook` 속성이 붙은 DOM 엘리먼트 |

| 메서드 | 뜻 |
|--------|-----|
| `this.pushEvent(event, payload, callback)` | 서버로 이벤트를 보낸다 |
| `this.handleEvent(event, callback)` | 서버가 보내는 이벤트를 받는다 |

정의 객체에서는 **함수만** `this`로 복사된다. 위 이름(`pushEvent`, `handleEvent`)과 `__`로 시작하는 이름은
wireview의 것이다 — 같은 이름으로 메서드를 쓰면 내장 동작을 덮는다. `this.__hookId` 같은 `__` 멤버는 내부이고
바뀔 수 있다.

## 서버와 주고받기

### 서버로 보내기 (pushEvent)

```javascript
window.wireview.hooks.InfiniteScroll = {
  mounted() {
    this.observer = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting) {
        this.loadMore();
      }
    });
    this.observer.observe(this.el.querySelector('.sentinel'));
  },

  loadMore() {
    // 콜백과 함께 서버로 보낸다
    this.pushEvent("load_more", { page: this.page }, (response) => {
      console.log("Server response:", response);
      if (response.hasMore) {
        this.page++;
      } else {
        this.observer.disconnect();
      }
    });
    this.page = (this.page || 1) + 1;
  },

  destroyed() {
    this.observer.disconnect();
  }
};
```

### 서버에서 받기 (handleEvent)

```javascript
window.wireview.hooks.Notification = {
  mounted() {
    // 서버가 push하는 이벤트의 핸들러를 등록한다
    this.handleEvent("show_toast", ({ message, type }) => {
      this.showToast(message, type);
    });

    this.handleEvent("highlight", ({ color }) => {
      this.el.style.backgroundColor = color;
      setTimeout(() => {
        this.el.style.backgroundColor = '';
      }, 1000);
    });
  },

  showToast(message, type) {
    // 토스트 구현
  }
};
```

### 서버 쪽 핸들러

```python
from wireview import Component


class Dashboard(Component):
    class Meta:
        template_name = "dashboard.html"

    items: list = []
    page: int = 1

    async def handle_hook_event(self, hook_id: str, event: str, payload: dict):
        """Handle events from JavaScript hooks.

        Args:
            hook_id: Unique identifier of the hook instance
            event: Event name sent by the hook
            payload: Event data from the hook

        Returns:
            Response data sent to the hook's callback (or None)
        """
        if event == "load_more":
            page = payload.get("page", 1)
            new_items = await self._fetch_items(page)
            self.items.extend(new_items)

            return {
                "hasMore": len(new_items) == 20,
                "count": len(new_items)
            }

        return None

    async def _notify_user(self, message: str):
        """Push an event to every hook in this component (a helper, so ``_``)."""
        await self.push_event("show_toast", {
            "message": message,
            "type": "success"
        })

    async def _highlight_item(self, hook_id: str):
        """Push an event to one specific hook."""
        await self.push_event(
            "highlight",
            {"color": "yellow"},
            hook_id=hook_id
        )
```

## 렌더가 건드리지 않게 (`wire-update="ignore"`)

훅이나 서드파티 위젯이 엘리먼트를 바꿔도, 그 컴포넌트가 다시 렌더되면 morph가 템플릿 모양으로 되돌린다.
`updated()`에서 다시 입히면 대부분 가려지지만, 재연결 때처럼 훅이 늦게 마운트되는 사이에는 틈이 보인다.
**`wire-update="ignore"`를 단 엘리먼트는 첫 렌더 뒤로 어떤 렌더도 건드리지 않는다.** 속성, 자식, 텍스트
모두다. Phoenix의 `phx-update="ignore"`와 같다.

```html
<div wire-hook="Banner" wire-update="ignore" hidden></div>
```

서버는 그 자리를 모른 채 계속 렌더하고 diff도 보낸다. 브라우저가 그 부분만 적용하지 않는다. 그래서 그 안에
서버가 바꿔야 하는 값을 두지 않는다. 엘리먼트가 템플릿에서 빠지면(`{% if %}`) 페이지에서도 빠진다.

## 한 엘리먼트에 훅 여러 개

이름을 공백으로 나열한다.

```html
<div wire-hook="Sortable Draggable Tooltip">
  <!-- 내용 -->
</div>
```

각 훅은 자기 인스턴스와 수명주기를 갖는다.

## 예제

### Chart.js

```javascript
window.wireview.hooks.Chart = {
  mounted() {
    const config = JSON.parse(this.el.dataset.config);
    this.chart = new Chart(this.el, config);

    // 서버가 보내는 데이터 갱신을 받는다
    this.handleEvent("update_data", ({ datasets }) => {
      this.chart.data.datasets = datasets;
      this.chart.update();
    });
  },

  beforeUpdate() {
    // morph 전에 차트 상태를 저장한다
    this.chartState = {
      animation: this.chart.options.animation
    };
  },

  updated() {
    // morph 후에 애니메이션을 복원한다
    this.chart.options.animation = this.chartState.animation;
  },

  destroyed() {
    this.chart.destroy();
  }
};
```

### CodeMirror

```javascript
window.wireview.hooks.CodeEditor = {
  mounted() {
    this.editor = CodeMirror(this.el, {
      mode: this.el.dataset.mode || "javascript",
      lineNumbers: true
    });

    // 변경을 디바운스해서 서버로 보낸다
    let timeout;
    this.editor.on("change", () => {
      clearTimeout(timeout);
      timeout = setTimeout(() => {
        this.pushEvent("content_changed", {
          content: this.editor.getValue()
        });
      }, 300);
    });

    // 서버가 보내는 내용을 받는다
    this.handleEvent("set_content", ({ content }) => {
      this.editor.setValue(content);
    });
  },

  destroyed() {
    this.editor.toTextArea();
  }
};
```

### 스크롤 위치 보존

```javascript
window.wireview.hooks.PreserveScroll = {
  beforeUpdate() {
    // morph 전에 저장
    this.scrollTop = this.el.scrollTop;
  },

  updated() {
    // morph 후에 복원
    this.el.scrollTop = this.scrollTop;
  }
};
```

### 오프라인 표시

```javascript
window.wireview.hooks.ConnectionStatus = {
  mounted() {
    this.updateStatus(true);
  },

  disconnected() {
    this.updateStatus(false);
  },

  reconnected() {
    this.updateStatus(true);
  },

  updateStatus(connected) {
    this.el.classList.toggle('connected', connected);
    this.el.classList.toggle('disconnected', !connected);
    this.el.textContent = connected ? '연결됨' : '다시 연결하는 중...';
  }
};
```

## 권장 사항

1. **`destroyed()`에서 반드시 정리한다.** observer 해제, 라이브러리 인스턴스 파괴, 이벤트 리스너 제거.
2. **상태 보존은 `beforeUpdate()`에서.** 스크롤 위치·포커스·선택 영역을 morph 전에 저장한다.
3. **초기화는 `mounted()`에서.** 엘리먼트가 페이지에 올라오기 전에 초기화하지 않는다.
4. **재연결을 다룬다.** 끊긴 동안 바뀌었을 데이터를 `reconnected()`에서 새로 읽는다. 끊긴 동안의 `pushEvent`는 버려지므로(#97) 서버가 꼭 알아야 하는 것은 `reconnected()`에서 다시 보낸다.
5. **폴링 대신 `handleEvent()`를 쓴다.** 데이터가 바뀌면 서버가 밀어 준다.
6. **훅 하나는 한 가지만 한다.** 필요하면 한 엘리먼트에 여러 개를 붙인다.

## API

### Component.handle_hook_event()

```python
async def handle_hook_event(
    self,
    hook_id: str,
    event: str,
    payload: dict[str, Any],
) -> Any: ...
```

클라이언트 훅이 `pushEvent()`로 보낸 이벤트를 받는다.

- `hook_id` — 훅 인스턴스의 고유 식별자
- `event` — 훅이 보낸 이벤트 이름
- `payload` — 훅이 보낸 데이터
- **반환** — 훅의 콜백으로 돌아갈 응답 데이터 (없으면 `None`)

### Component.push_event()

```python
async def push_event(
    self,
    event: str,
    payload: dict[str, Any] | None = None,
    hook_id: str | None = None,
) -> None: ...
```

클라이언트 훅으로 이벤트를 보낸다.

- `event` — 보낼 이벤트 이름
- `payload` — 데이터 (기본: 빈 dict)
- `hook_id` — 특정 훅 인스턴스만 (`None`이면 전체)

## DOM morph 콜백

### wireview.dom.onBeforeElUpdated()

갱신 중 엘리먼트가 morph되기 직전에 실행되는 콜백을 더한다. 서버 렌더 결과가 덮어써 버릴
클라이언트 쪽 속성이나 상태를 지키는 데 쓴다. 여러 번 부르면 콜백이 모두 더해져 더한 순서대로 돌고,
돌려받은 함수를 부르면 그 콜백만 빠진다.

```javascript
const remove = wireview.dom.onBeforeElUpdated((fromEl, toEl) => {
  // fromEl: 지금 DOM에 있는 엘리먼트
  // toEl: 그것을 대체할 새 엘리먼트
});
remove();  // 더 이상 필요 없을 때
```

**JS가 붙인 속성 지키기**

```javascript
// data-js-* 속성을 보존한다
wireview.dom.onBeforeElUpdated((fromEl, toEl) => {
  for (const attr of fromEl.attributes) {
    if (attr.name.startsWith('data-js-')) {
      toEl.setAttribute(attr.name, attr.value);
    }
  }
});
```

**Alpine.js 상태 보존**

```javascript
wireview.dom.onBeforeElUpdated((fromEl, toEl) => {
  if (fromEl._x_dataStack) {
    window.Alpine.clone(fromEl, toEl);
  }
});
```

**CSS 트랜지션 유지**

```javascript
wireview.dom.onBeforeElUpdated((fromEl, toEl) => {
  if (fromEl.hasAttribute('data-transitioning')) {
    toEl.setAttribute('data-transitioning', fromEl.getAttribute('data-transitioning'));
  }
});
```

### Phoenix LiveView 대응

| 기능 | Phoenix LiveView | django-wireview |
|------|------------------|-----------------|
| 설정 위치 | `LiveSocket` 생성자 옵션 | `wireview.dom.onBeforeElUpdated()` |
| 콜백 시그니처 | `(fromEl, toEl)` | `(fromEl, toEl)` |
| 반환값 | 무시 | 무시 |
| 호출 대상 | 모든 노드 | 엘리먼트 노드만 |
