Skip to content

Commit 98e9ef2

Browse files
committed
add hook useVegaSignalEmbed
1 parent 3fc2a54 commit 98e9ef2

4 files changed

Lines changed: 197 additions & 139 deletions

File tree

chartlets.js/packages/lib/src/plugins/vega/hooks/useSignalListeners.test.ts

Lines changed: 1 addition & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44
* https://opensource.org/licenses/MIT.
55
*/
66

7-
import { describe, it, expect, vi } from "vitest";
7+
import { describe, it, expect } from "vitest";
88
import { renderHook, act } from "@testing-library/react";
99
import type { TopLevelSpec } from "vega-lite";
1010
import { useSignalListeners } from "./useSignalListeners";
1111
import { createChangeHandler } from "@/plugins/mui/common.test";
12-
import type { Result as VegaEmbedResult } from "vega-embed";
1312

1413
const chart: TopLevelSpec = {
1514
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
@@ -96,99 +95,4 @@ describe("useSignalListeners", () => {
9695
value: [1, 2, 3],
9796
});
9897
});
99-
100-
it("should register signal listeners on embed", () => {
101-
const { result } = renderHook(() =>
102-
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
103-
);
104-
105-
const view = createMockView();
106-
107-
act(() => {
108-
result.current.onEmbed({ view } as unknown as VegaEmbedResult);
109-
});
110-
111-
// Supported signals: sel_point, sel_interval, sel_point_a
112-
expect(view.addSignalListener).toHaveBeenCalledTimes(3);
113-
114-
const names = view.addSignalListener.mock.calls.map(([name]) => name);
115-
expect(names).toEqual(
116-
expect.arrayContaining(["sel_point", "sel_interval", "sel_point_a"]),
117-
);
118-
119-
// Unsupported "wheel" should not be registered
120-
expect(names).not.toContain("sel_interval_b");
121-
});
122-
123-
it("should remove old listeners when embedding again", () => {
124-
const { result } = renderHook(() =>
125-
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
126-
);
127-
128-
const view1 = createMockView();
129-
const view2 = createMockView();
130-
131-
act(() => {
132-
result.current.onEmbed({ view: view1 } as unknown as VegaEmbedResult);
133-
});
134-
135-
const attachedToView1 = view1.addSignalListener.mock.calls.map(
136-
([name, fn]) => ({ name, fn }),
137-
);
138-
139-
act(() => {
140-
result.current.onEmbed({ view: view2 } as unknown as VegaEmbedResult);
141-
});
142-
143-
expect(view1.removeSignalListener).toHaveBeenCalledTimes(
144-
attachedToView1.length,
145-
);
146-
147-
for (const { name, fn } of attachedToView1) {
148-
expect(view1.removeSignalListener).toHaveBeenCalledWith(name, fn);
149-
}
150-
151-
expect(view2.addSignalListener).toHaveBeenCalledTimes(3);
152-
});
153-
154-
it("should cleanup listeners on unmount", () => {
155-
const { result, unmount } = renderHook(() =>
156-
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
157-
);
158-
159-
const view = createMockView();
160-
161-
act(() => {
162-
result.current.onEmbed({ view } as unknown as VegaEmbedResult);
163-
});
164-
165-
const attached = view.addSignalListener.mock.calls.map(([name, fn]) => ({
166-
name,
167-
fn,
168-
}));
169-
170-
unmount();
171-
172-
expect(view.removeSignalListener).toHaveBeenCalledTimes(attached.length);
173-
for (const { name, fn } of attached) {
174-
expect(view.removeSignalListener).toHaveBeenCalledWith(name, fn);
175-
}
176-
});
177-
178-
it("should do nothing if embed result has no view", () => {
179-
const { result } = renderHook(() =>
180-
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
181-
);
182-
183-
act(() => {
184-
result.current.onEmbed({} as unknown as VegaEmbedResult);
185-
});
186-
});
18798
});
188-
189-
function createMockView() {
190-
return {
191-
addSignalListener: vi.fn(),
192-
removeSignalListener: vi.fn(),
193-
};
194-
}

chartlets.js/packages/lib/src/plugins/vega/hooks/useSignalListeners.ts

Lines changed: 3 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
* https://opensource.org/licenses/MIT.
55
*/
66

7-
import { useCallback, useMemo, useEffect, useRef } from "react";
7+
import { useCallback, useMemo } from "react";
88
import type { Result as VegaEmbedResult } from "vega-embed";
99
import type { TopLevelSpec } from "vega-lite";
1010

1111
import { type ComponentChangeHandler } from "@/index";
1212
import { isString } from "@/utils/isString";
1313
import { isObject } from "@/utils/isObject";
14+
import { useVegaSignalEmbed } from "./useVegaSignalEmbed";
1415

1516
type SignalHandler = (signalName: string, signalValue: unknown) => void;
1617

@@ -112,47 +113,7 @@ export function useSignalListeners(
112113
return signalListeners;
113114
}, [signalNames, handleSignal]);
114115

115-
// Keep cleanup in a ref so it can run on re-embed and unmount.
116-
const cleanupRef = useRef<null | (() => void)>(null);
117-
118-
const onEmbed = useCallback(
119-
(result: VegaEmbedResult) => {
120-
cleanupRef.current?.();
121-
cleanupRef.current = null;
122-
123-
const view = result?.view;
124-
if (!view) return;
125-
126-
/*
127-
* Keep track of the exact listener functions registered on the Vega view.
128-
* Vega requires the same function reference for removal, so we store them
129-
* here in order to properly clean them up on re-embed or unmount.
130-
*/
131-
const attachedListeners: Array<{
132-
name: string;
133-
fn: (name: string, value: unknown) => void;
134-
}> = [];
135-
136-
for (const [signalName, handler] of Object.entries(signalListenerMap)) {
137-
const fn = (name: string, value: unknown) => handler(name, value);
138-
view.addSignalListener(signalName, fn);
139-
attachedListeners.push({ name: signalName, fn });
140-
}
141-
142-
cleanupRef.current = () => {
143-
for (const { name, fn } of attachedListeners)
144-
view.removeSignalListener(name, fn);
145-
};
146-
},
147-
[signalListenerMap],
148-
);
149-
150-
useEffect(() => {
151-
return () => {
152-
cleanupRef.current?.();
153-
cleanupRef.current = null;
154-
};
155-
}, []);
116+
const onEmbed = useVegaSignalEmbed(signalListenerMap);
156117

157118
return { onEmbed, signalListenerMap };
158119
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright (c) 2019-2026 by Brockmann Consult Development team
3+
* Permissions are hereby granted under the terms of the MIT License:
4+
* https://opensource.org/licenses/MIT.
5+
*/
6+
7+
import { describe, it, expect, vi } from "vitest";
8+
import { renderHook, act } from "@testing-library/react";
9+
import type { Result as VegaEmbedResult } from "vega-embed";
10+
11+
import { useVegaSignalEmbed } from "./useVegaSignalEmbed";
12+
13+
type SignalHandler = (signalName: string, signalValue: unknown) => void;
14+
15+
describe("useVegaSignalEmbed", () => {
16+
it("should register signal listeners on embed", () => {
17+
const signalListenerMap: Record<string, SignalHandler> = {
18+
sel_point: vi.fn(),
19+
sel_interval: vi.fn(),
20+
sel_point_a: vi.fn(),
21+
};
22+
23+
const { result } = renderHook(() => useVegaSignalEmbed(signalListenerMap));
24+
25+
const view = createMockView();
26+
27+
act(() => {
28+
result.current({ view } as unknown as VegaEmbedResult);
29+
});
30+
31+
expect(view.addSignalListener).toHaveBeenCalledTimes(3);
32+
33+
const names = view.addSignalListener.mock.calls.map(([name]) => name);
34+
expect(names).toEqual(
35+
expect.arrayContaining(["sel_point", "sel_interval", "sel_point_a"]),
36+
);
37+
});
38+
39+
it("should remove old listeners when embedding again", () => {
40+
const signalListenerMap: Record<string, SignalHandler> = {
41+
sel_point: vi.fn(),
42+
sel_interval: vi.fn(),
43+
};
44+
45+
const { result } = renderHook(() => useVegaSignalEmbed(signalListenerMap));
46+
47+
const view1 = createMockView();
48+
const view2 = createMockView();
49+
50+
act(() => {
51+
result.current({ view: view1 } as unknown as VegaEmbedResult);
52+
});
53+
54+
const attachedToView1 = view1.addSignalListener.mock.calls.map(
55+
([name, fn]) => ({ name, fn }),
56+
);
57+
58+
act(() => {
59+
result.current({ view: view2 } as unknown as VegaEmbedResult);
60+
});
61+
62+
expect(view1.removeSignalListener).toHaveBeenCalledTimes(
63+
attachedToView1.length,
64+
);
65+
66+
for (const { name, fn } of attachedToView1) {
67+
expect(view1.removeSignalListener).toHaveBeenCalledWith(name, fn);
68+
}
69+
70+
expect(view2.addSignalListener).toHaveBeenCalledTimes(2);
71+
});
72+
73+
it("should cleanup listeners on unmount", () => {
74+
const signalListenerMap: Record<string, SignalHandler> = {
75+
sel_point: vi.fn(),
76+
sel_interval: vi.fn(),
77+
};
78+
79+
const { result, unmount } = renderHook(() =>
80+
useVegaSignalEmbed(signalListenerMap),
81+
);
82+
83+
const view = createMockView();
84+
85+
act(() => {
86+
result.current({ view } as unknown as VegaEmbedResult);
87+
});
88+
89+
const attached = view.addSignalListener.mock.calls.map(([name, fn]) => ({
90+
name,
91+
fn,
92+
}));
93+
94+
unmount();
95+
96+
expect(view.removeSignalListener).toHaveBeenCalledTimes(attached.length);
97+
98+
for (const { name, fn } of attached) {
99+
expect(view.removeSignalListener).toHaveBeenCalledWith(name, fn);
100+
}
101+
});
102+
103+
it("should do nothing if embed result has no view", () => {
104+
const signalListenerMap: Record<string, SignalHandler> = {
105+
sel_point: vi.fn(),
106+
};
107+
108+
const { result } = renderHook(() => useVegaSignalEmbed(signalListenerMap));
109+
110+
act(() => {
111+
result.current({} as VegaEmbedResult);
112+
});
113+
});
114+
115+
it("should register no listeners if the signal listener map is empty", () => {
116+
const { result } = renderHook(() => useVegaSignalEmbed({}));
117+
118+
const view = createMockView();
119+
120+
act(() => {
121+
result.current({ view } as unknown as VegaEmbedResult);
122+
});
123+
124+
expect(view.addSignalListener).not.toHaveBeenCalled();
125+
expect(view.removeSignalListener).not.toHaveBeenCalled();
126+
});
127+
});
128+
129+
function createMockView() {
130+
return {
131+
addSignalListener: vi.fn(),
132+
removeSignalListener: vi.fn(),
133+
};
134+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright (c) 2019-2026 by Brockmann Consult Development team
3+
* Permissions are hereby granted under the terms of the MIT License:
4+
* https://opensource.org/licenses/MIT.
5+
*/
6+
7+
import { useCallback, useEffect, useRef } from "react";
8+
import type { Result as VegaEmbedResult } from "vega-embed";
9+
10+
type SignalHandler = (signalName: string, signalValue: unknown) => void;
11+
12+
export function useVegaSignalEmbed(
13+
signalListenerMap: Record<string, SignalHandler>,
14+
): (result: VegaEmbedResult) => void {
15+
// Keep cleanup in a ref so it can run on re-embed and unmount.
16+
const cleanupRef = useRef<null | (() => void)>(null);
17+
18+
const onEmbed = useCallback(
19+
(result: VegaEmbedResult) => {
20+
cleanupRef.current?.();
21+
cleanupRef.current = null;
22+
23+
const view = result?.view;
24+
if (!view) return;
25+
26+
/*
27+
* Keep track of the exact listener functions registered on the Vega view.
28+
* Vega requires the same function reference for removal, so we store them
29+
* here in order to properly clean them up on re-embed or unmount.
30+
*/
31+
const attachedListeners: Array<{
32+
name: string;
33+
fn: (name: string, value: unknown) => void;
34+
}> = [];
35+
36+
for (const [signalName, handler] of Object.entries(signalListenerMap)) {
37+
const fn = (name: string, value: unknown) => handler(name, value);
38+
view.addSignalListener(signalName, fn);
39+
attachedListeners.push({ name: signalName, fn });
40+
}
41+
42+
cleanupRef.current = () => {
43+
for (const { name, fn } of attachedListeners) {
44+
view.removeSignalListener(name, fn);
45+
}
46+
};
47+
},
48+
[signalListenerMap],
49+
);
50+
51+
useEffect(() => {
52+
return () => {
53+
cleanupRef.current?.();
54+
cleanupRef.current = null;
55+
};
56+
}, []);
57+
58+
return onEmbed;
59+
}

0 commit comments

Comments
 (0)