Skip to main content

devtools-bidirectional

Two-way event patterns between devtools panel and application. App-to-devtools observation, devtools-to-app commands, time-travel debugging with snapshots and revert. structuredClone for snapshot safety, distinct event suffixes for observation vs commands, serializable payloads only.

소스 정보

저장소
TanStack/devtools
최근 소스 활동
2026년 3월 11일 11:25
감지된 SKILL.md 언어
영어
스타
510
포크
103

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
devtools-bidirectional
description
Two-way event patterns between devtools panel and application. App-to-devtools observation, devtools-to-app commands, time-travel debugging with snapshots and revert. structuredClone for snapshot safety, distinct event suffixes for observation vs commands, serializable payloads only.
type
core
library
@tanstack/devtools-event-client
library_version
0.10.12
requires
devtools-event-client
sources
["packages/event-bus-client/src/plugin.ts","docs/bidirectional-communication.md"]
# devtools-bidirectional > **Prerequisite:** Read and understand the `devtools-event-client` skill first. This skill builds on `EventClient`, its event map types, `emit()`/`on()` API, pluginId namespacing, connection lifecycle, and singleton pattern. Everything here assumes you already have a working `EventClient` instance. Two-way communication between your application and a TanStack Devtools panel using `EventClient`. The same client instance handles both directions: the app emits observation events that the panel listens to, and the panel emits command events that the app listens to. ## Core Concept `EventClient` is not unidirectional. Both `emit()` and `on()` work from either side -- application code or panel code -- on the same shared event bus. The direction is a convention you establish through your event map design, not a limitation of the API. ``` App code calls: client.emit('state-update', ...) // observation Panel code calls: client.on('state-update', ...) // observation Panel code calls: client.emit('set-state', ...) // command App code calls: client.on('set-state', ...) // command ``` ## Core Patterns ### 1. App-to-Devtools Observation The app emits state changes. The panel listens and renders. **Event map and client (shared module):** ```ts import { EventClient } from '@tanstack/devtools-event-client' type CounterEvents = { // Observation: app -> panel 'state-update': { count: number; updatedAt: number } } class CounterDevtoolsClient extends EventClient<CounterEvents> { constructor() { super({ pluginId: 'counter-inspector', enabled: process.env.NODE_ENV !== 'production', }) } } export const counterClient = new CounterDevtoolsClient() ``` **App side -- emit on state changes:** ```ts import { counterClient } from './counter-devtools-client' function increment() { count += 1 counterClient.emit('state-update', { count, updatedAt: Date.now(), }) } ``` **Panel side -- listen and display:** ```ts import { counterClient } from './counter-devtools-client' const cleanup = counterClient.on('state-update', (event) => { // event.payload.count // event.payload.updatedAt renderPanel(event.payload) }) ``` ### 2. Devtools-to-App Commands The panel sends commands. The app listens and mutates state. **Extend the event map with command events:** ```ts type CounterEvents = { // Observation: app -> panel 'state-update': { count: number; updatedAt: number } // Commands: panel -> app reset: void 'set-count': { count: number } } ``` **Panel side -- emit commands on user interaction:** ```ts import { counterClient } from './counter-devtools-client' function handleResetClick() { counterClient.emit('reset', undefined) } function handleSetCount(newCount: number) { counterClient.emit('set-count', { count: newCount }) } ``` **App side -- listen for commands and react:** ```ts import { counterClient } from './counter-devtools-client' counterClient.on('reset', () => { count = 0 // Re-emit observation so panel updates counterClient.emit('state-update', { count, updatedAt: Date.now(), }) }) counterClient.on('set-count', (event) => { count = event.payload.count counterClient.emit('state-update', { count, updatedAt: Date.now(), }) }) ``` The command handler re-emits an observation event after mutating state. This closes the loop so the panel sees the result of its own command. ### 3. Time-Travel Debugging Combine observation (snapshots) with commands (revert) to build a time-travel slider. **Event map:** ```ts type TimeTravelEvents = { // Observation: app -> panel snapshot: { state: unknown; timestamp: number; label: string } // Command: panel -> app revert: { state: unknown } } class TimeTravelClient extends EventClient<TimeTravelEvents> { constructor() { super({ pluginId: 'time-travel', enabled: process.env.NODE_ENV !== 'production', }) } } export const timeTravelClient = new TimeTravelClient() ``` **App side -- emit snapshots with structuredClone:** ```ts import { timeTravelClient } from './time-travel-client' function applyAction(action: { type: string; payload: unknown }) { state = reducer(state, action) timeTravelClient.emit('snapshot', { state: structuredClone(state), timestamp: Date.now(), label: action.type, }) } // Listen for revert commands from devtools timeTravelClient.on('revert', (event) => { state = event.payload.state rerender() }) ``` `structuredClone(state)` is required here. Without it, the snapshot payload holds a reference to the live state object. When the app mutates state later, all previously stored snapshots in the panel are corrupted because they point to the same object. **Panel side -- collect snapshots and revert:** ```tsx import { timeTravelClient } from './time-travel-client' function TimeTravelPanel() { const [snapshots, setSnapshots] = useState< Array<{ state: unknown; timestamp: number; label: string }> >([]) const [index, setIndex] = useState(0) useEffect(() => { return timeTravelClient.on('snapshot', (event) => { setSnapshots((prev) => [...prev, event.payload]) setIndex((prev) => prev + 1) }) }, []) const handleSliderChange = (newIndex: number) => { setIndex(newIndex) timeTravelClient.emit('revert', { state: snapshots[newIndex].state, }) } return ( <div> <input type="range" min={0} max={snapshots.length - 1} value={index} onChange={(e) => handleSliderChange(Number(e.target.value))} /> <p> {snapshots[index]?.label} ( {new Date(snapshots[index]?.timestamp).toLocaleTimeString()}) </p> <pre>{JSON.stringify(snapshots[index]?.state, null, 2)}</pre> </div> ) } ``` After the app handles `revert`, it should re-emit a `snapshot` so the panel timeline stays current. The revert handler in the app side example above does not re-emit -- add it if your UI needs the timeline to update after a revert: ```ts timeTravelClient.on('revert', (event) => { state = event.payload.state rerender() // Optional: re-emit so the timeline reflects the revert timeTravelClient.emit('snapshot', { state: structuredClone(state), timestamp: Date.now(), label: 'revert', }) }) ``` ### 4. Bidirectional Event Map Design When a single plugin needs both observation and command events, define them all in one event map. Use naming conventions to distinguish direction: ```ts type StoreInspectorEvents = { // Observation: app -> panel (describe what happened) 'state-update': { storeName: string; state: unknown; timestamp: number } 'action-dispatched': { storeName: string; action: string; payload: unknown } 'error-caught': { storeName: string; error: string; stack?: string } // Commands: panel -> app (describe what to do) 'set-state': { storeName: string; state: unknown } 'dispatch-action': { storeName: string; action: string; payload: unknown } reset: void revert: { state: unknown } } ``` Naming convention: - **Observation events** describe what happened: `state-update`, `action-dispatched`, `error-caught`, `snapshot` - **Command events** describe what to do: `set-state`, `dispatch-action`, `reset`, `revert` This distinction is purely a convention in your event map keys. The `EventClient` API is the same for both. But maintaining it makes your event map self-documenting and prevents confusion about which side emits vs listens. **Full bidirectional wiring with one client:** ```ts import { EventClient } from '@tanstack/devtools-event-client' type StoreInspectorEvents = { 'state-update': { storeName: string; state: unknown; timestamp: number } 'set-state': { storeName: string; state: unknown } reset: void } class StoreInspectorClient extends EventClient<StoreInspectorEvents> { constructor() { super({ pluginId: 'store-inspector', enabled: process.env.NODE_ENV !== 'production', }) } } export const storeInspector = new StoreInspectorClient() ``` **App side:** ```ts import { storeInspector } from './store-inspector-client' // Observation: emit state changes function updateStore(storeName: string, newState: unknown) { stores[storeName] = newState storeInspector.emit('state-update', { storeName, state: structuredClone(newState), timestamp: Date.now(), }) } // Command handlers: listen for panel commands storeInspector.on('set-state', (event) => { const { storeName, state } = event.payload stores[storeName] = state storeInspector.emit('state-update', { storeName, state: structuredClone(state), timestamp: Date.now(), }) }) storeInspector.on('reset', () => { for (const storeName of Object.keys(stores)) { stores[storeName] = initialStates[storeName] storeInspector.emit('state-update', { storeName, state: structuredClone(initialStates[storeName]), timestamp: Date.now(), }) } }) ``` **Panel side:** ```ts import { storeInspector } from './store-inspector-client' // Observation: listen for state changes storeInspector.on('state-update', (event) => { renderStore(event.payload.storeName, event.payload.state) }) // Commands: emit on user action function handleEditState(storeName: string, newState: unknown) { storeInspector.emit('set-state', { storeName, state: newState }) } function handleReset() { storeInspector.emit('reset', undefined) } ``` ## Debouncing Frequent Observations High-frequency state changes (e.g., mouse tracking, animation frames) can flood the event bus. Debounce on the emit side: ```ts import { storeInspector } from './store-inspector-client' let debounceTimer: ReturnType<typeof setTimeout> | null = null function emitStateUpdate(storeName: string, state: unknown) { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { storeInspector.emit('state-update', { storeName, state: structuredClone(state), timestamp: Date.now(), }) }, 16) // ~60fps cap } ``` Do not debounce command events. Commands are user-initiated and infrequent. ## Common Mistakes ### 1. Not using structuredClone for snapshots (HIGH) Without `structuredClone`, snapshot payloads hold references to the live state object. When the app mutates state later, every stored snapshot in the panel is silently corrupted. Wrong: ```ts timeTravelClient.emit('snapshot', { state, timestamp: Date.now(), label: action.type, }) ``` The panel stores `event.payload.state`, which is a reference to the app's `state` variable. On the next mutation, the panel's stored snapshot now reflects the new state, not the historical state. Correct: ```ts timeTravelClient.emit('snapshot', { state: structuredClone(state), timestamp: Date.now(), label: action.type, }) ``` `structuredClone` creates a deep copy. The snapshot is frozen in time regardless of future mutations. This applies to any observation event where the panel accumulates historical data -- not just time-travel. ### 2. Non-serializable payloads in cross-tab scenarios (HIGH) When using the server event bus (WebSocket/SSE/BroadcastChannel), payloads are serialized for transport. Functions, DOM nodes, class instances with methods, `Map`, `Set`, `WeakRef`, and circular references all fail silently or lose data. This is especially dangerous in bidirectional patterns because command payloads flow panel-to-app and may cross transport boundaries. Wrong: ```ts storeInspector.emit('set-state', { storeName: 'main', state: { items: new Map([['a', 1]]), // Map -- lost on serialization onClick: () => alert('hi'), // Function -- lost on serialization ref: document.getElementById('x'), // DOM node -- lost on serialization }, }) ``` Correct: ```ts storeInspector.emit('set-state', { storeName: 'main', state: {
GitHub에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기