From bf7c3dfb156484762f97e5e20fc9a7681122262c Mon Sep 17 00:00:00 2001 From: Dmitry Radkovskiy Date: Fri, 20 Mar 2026 06:14:20 +0000 Subject: [PATCH 1/8] feat: add Track.OnMount component for declarative mount tracking --- src/Track.tsx | 9 +++++++++ src/index.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 src/Track.tsx diff --git a/src/Track.tsx b/src/Track.tsx new file mode 100644 index 0000000..dadb111 --- /dev/null +++ b/src/Track.tsx @@ -0,0 +1,9 @@ +import { useMountEvent } from "./hooks"; +import { EventObject } from "./types"; + +export const Track = { + OnMount: (props: EventObject) => { + useMountEvent(props); + return null; + } +}; diff --git a/src/index.ts b/src/index.ts index dc3d29a..e26476b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from "./context" -export * from "./hooks" \ No newline at end of file +export * from "./hooks" +export * from "./Track" From 5c11cf792a3f111ec1f33ccded5d4395e171ab5d Mon Sep 17 00:00:00 2001 From: Dmitry Radkovskiy Date: Fri, 20 Mar 2026 06:19:20 +0000 Subject: [PATCH 2/8] feat: allow children in Track.OnMount component --- src/Track.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Track.tsx b/src/Track.tsx index dadb111..48e5e1d 100644 --- a/src/Track.tsx +++ b/src/Track.tsx @@ -2,8 +2,8 @@ import { useMountEvent } from "./hooks"; import { EventObject } from "./types"; export const Track = { - OnMount: (props: EventObject) => { - useMountEvent(props); - return null; - } + OnMount: (props: EventObject & {children?: React.ReactNode}) => { + useMountEvent(props); + return props.children ?? null; + } }; From e9413342ccf8a472ae1ff2423a5bb5a0ffa22b97 Mon Sep 17 00:00:00 2001 From: Dmitry Radkovskiy Date: Fri, 20 Mar 2026 08:58:20 +0000 Subject: [PATCH 3/8] feat: add Track.Impression component and intersection observer hooks --- src/Track.tsx | 50 ++++++++- src/hooks/index.ts | 4 +- src/hooks/useIntersectionObserver.ts | 83 +++++++++++++++ src/hooks/useMergeRefs.ts | 20 ++++ tests/Track.test.tsx | 151 +++++++++++++++++++++++++++ 5 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useIntersectionObserver.ts create mode 100644 src/hooks/useMergeRefs.ts create mode 100644 tests/Track.test.tsx diff --git a/src/Track.tsx b/src/Track.tsx index 48e5e1d..b2bba60 100644 --- a/src/Track.tsx +++ b/src/Track.tsx @@ -1,9 +1,55 @@ -import { useMountEvent } from "./hooks"; +import React, { Children, cloneElement, isValidElement } from "react"; +import { useMountEvent, useIntersectionObserver, useMergeRefs, UseIntersectionObserverOptions } from "./hooks"; import { EventObject } from "./types"; +import { useTracker } from "./context"; +import { parseEventArgs } from "./utils"; export const Track = { - OnMount: (props: EventObject & {children?: React.ReactNode}) => { + OnMount: (props: EventObject & { children?: React.ReactNode }) => { useMountEvent(props); return props.children ?? null; + }, + + Impression: ({ + children, + options, + ...eventProps + }: EventObject & { + children: React.ReactNode; + options?: UseIntersectionObserverOptions; + }) => { + const { sendEvent } = useTracker(); + const { eventName, params } = parseEventArgs(eventProps, undefined); + + // Keep track if we've already tracked this to handle freezeOnceVisible manually + // because the ref callback might be called multiple times during renders + const trackedRef = React.useRef(false); + + const { ref: impressionRef } = useIntersectionObserver({ + freezeOnceVisible: true, + ...options, + onChange: (isIntersecting) => { + if (isIntersecting) { + const freeze = options?.freezeOnceVisible ?? true; + if (freeze && trackedRef.current) + return; + + sendEvent(eventName, params); + if (freeze) + trackedRef.current = true; + } + }, + }); + + const child = Children.only(children); + const hasRef = isValidElement(child) && (child as any)?.ref != null; + + const ref = useMergeRefs(hasRef ? [(child as any).ref, impressionRef] : [impressionRef]); + + return hasRef ? ( + cloneElement(child as any, { ref }) + ) : ( +
{child}
+ ); } }; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a25cded..2dd57e6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,3 @@ -export * from "./useMountEvent" \ No newline at end of file +export * from "./useMountEvent" +export * from "./useIntersectionObserver" +export * from "./useMergeRefs" \ No newline at end of file diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 0000000..763287a --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef, useCallback } from "react"; + +export type UseIntersectionObserverOptions = { + root?: Element | Document | null; + rootMargin?: string; + threshold?: number | number[]; + freezeOnceVisible?: boolean; + onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; +}; + +type IntersectionReturn = { + ref: (node?: Element | null) => void; +}; + +export function useIntersectionObserver({ + threshold = 0, + root = null, + rootMargin = "0%", + freezeOnceVisible = false, + onChange, +}: UseIntersectionObserverOptions = {}): IntersectionReturn { + const nodeRef = useRef(null); + const observerRef = useRef(null); + const frozenRef = useRef(false); + + const callbackRef = useRef(undefined); + callbackRef.current = onChange; + + const setRef = useCallback((node: Element | null | undefined) => { + // If the node hasn't changed, do nothing + if (nodeRef.current === node) return; + + // Disconnect old observer if it exists + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + nodeRef.current = node || null; + + // Ensure we have a ref to observe and the browser supports the API + if (!nodeRef.current || !("IntersectionObserver" in window)) return; + + // Skip if already frozen + if (frozenRef.current && freezeOnceVisible) return; + + const observer = new IntersectionObserver( + (entries: IntersectionObserverEntry[]): void => { + const thresholds = Array.isArray(observer.thresholds) ? observer.thresholds : [observer.thresholds]; + + entries.forEach((entry) => { + const isIntersecting = + entry.isIntersecting && thresholds.some((t) => entry.intersectionRatio >= t); + + if (callbackRef.current) { + callbackRef.current(isIntersecting, entry); + } + + if (isIntersecting && freezeOnceVisible) { + frozenRef.current = true; + observer.disconnect(); + observerRef.current = null; + } + }); + }, + { threshold, root, rootMargin }, + ); + + observer.observe(nodeRef.current); + observerRef.current = observer; + }, [threshold, root, rootMargin, freezeOnceVisible]); + + useEffect(() => { + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, []); + + return { ref: setRef }; +} diff --git a/src/hooks/useMergeRefs.ts b/src/hooks/useMergeRefs.ts new file mode 100644 index 0000000..6458e0b --- /dev/null +++ b/src/hooks/useMergeRefs.ts @@ -0,0 +1,20 @@ +import { useMemo, Ref, RefCallback } from "react"; + +export function useMergeRefs(refs: Array | undefined>): RefCallback | null { + return useMemo(() => { + if (refs.every((ref) => ref == null)) { + return null; + } + + return (value) => { + refs.forEach((ref) => { + if (typeof ref === "function") { + ref(value); + } else if (ref != null) { + (ref as React.MutableRefObject).current = value; + } + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, refs); +} diff --git a/tests/Track.test.tsx b/tests/Track.test.tsx new file mode 100644 index 0000000..ae506e4 --- /dev/null +++ b/tests/Track.test.tsx @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { render, screen } from "@testing-library/react" +import { TrackRoot } from "../src/context" +import { Track } from "../src/Track" +import React, { forwardRef } from "react" + +const observers: any[] = [] + +const setupIntersectionObserverMock = () => { + class IntersectionObserverMock { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() + thresholds = [0] + constructor(public callback: IntersectionObserverCallback) { + observers.push(this) + } + } + + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock) + return IntersectionObserverMock +} + +describe("Track.Impression (deeply mocked, not very useful)", () => { + beforeEach(() => { + observers.length = 0 + setupIntersectionObserverMock() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + const triggerIntersection = (isIntersecting: boolean) => { + const observer = observers[0] + if (observer) { + const callback = observer.callback + callback([{ isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 } as any], observer) + } + } + + it("should send event when element becomes visible", () => { + const onEvent = vi.fn() + + render( + + +
Test Banner
+
+
+ ) + + expect(onEvent).not.toHaveBeenCalled() + + triggerIntersection(true) + + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith("banner_view", {}) + }) + + it("should send event with params", () => { + const onEvent = vi.fn() + const params = { banner_id: "123" } + + render( + + +
Test Banner
+
+
+ ) + + triggerIntersection(true) + + expect(onEvent).toHaveBeenCalledWith("banner_view", params) + }) + + it("should only send event once by default (freezeOnceVisible: true)", () => { + const onEvent = vi.fn() + + render( + + +
Test Banner
+
+
+ ) + + triggerIntersection(true) + triggerIntersection(false) + triggerIntersection(true) + + expect(onEvent).toHaveBeenCalledTimes(1) + }) + + it("should send event multiple times if freezeOnceVisible is false", () => { + const onEvent = vi.fn() + + render( + + +
Test Banner
+
+
+ ) + + triggerIntersection(true) + triggerIntersection(false) + triggerIntersection(true) + + expect(onEvent).toHaveBeenCalledTimes(2) + }) + + it("should wrap non-ref children in a div", () => { + const onEvent = vi.fn() + + const { container } = render( + + + Test Banner + + + ) + + expect(container.innerHTML).toBe('
Test Banner
') + }) + + it("should merge refs for children that already have a ref", () => { + const onEvent = vi.fn() + const innerRef = vi.fn() + + const ComponentWithRef = forwardRef( + ({ children }, ref) =>
{children}
+ ) + + render( + + + Test Banner + + + ) + + // innerRef should be called with the DOM element + expect(innerRef).toHaveBeenCalledWith(screen.getByTestId("inner")) + + // And tracking should still work + triggerIntersection(true) + expect(onEvent).toHaveBeenCalledTimes(1) + }) +}) From 185c046428272af4f984be45b016a729490dc615 Mon Sep 17 00:00:00 2001 From: Dmitry Radkovskiy Date: Fri, 20 Mar 2026 09:07:17 +0000 Subject: [PATCH 4/8] feat: add Track.OnClick component for event tracking on click events --- src/Track.tsx | 25 +++++++++ tests/track-onclick.test.tsx | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/track-onclick.test.tsx diff --git a/src/Track.tsx b/src/Track.tsx index b2bba60..08f6e76 100644 --- a/src/Track.tsx +++ b/src/Track.tsx @@ -51,5 +51,30 @@ export const Track = { ) : (
{child}
); + }, + + OnClick: (props: EventObject & { children: React.ReactNode | ((track: () => void) => React.ReactNode) }) => { + const { sendEvent } = useTracker(); + const { eventName, params } = parseEventArgs(props, undefined); + + const track = React.useCallback(() => sendEvent(eventName, params), [sendEvent, eventName, params]); + + if (typeof props.children == "function") + return props.children(track); + + return Children.map(props.children, child => { + if (!isValidElement(child)) { + return child; + } + + return cloneElement(child as React.ReactElement, { + onClickCapture: (e: React.MouseEvent) => { + track(); + if (child.props && typeof child.props.onClickCapture === 'function') { + child.props.onClickCapture(e); + } + } + }); + }); } }; diff --git a/tests/track-onclick.test.tsx b/tests/track-onclick.test.tsx new file mode 100644 index 0000000..5374651 --- /dev/null +++ b/tests/track-onclick.test.tsx @@ -0,0 +1,101 @@ +import React from "react" +import { describe, it, expect, vi } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { TrackRoot } from "../src/context" +import { Track } from "../src/Track" + +describe("Track.OnClick", () => { + it("should send event when clicked and preserve existing onClickCapture", () => { + const onEvent = vi.fn() + const existingCapture = vi.fn() + const existingClick = vi.fn() + + render( + + + + + + ) + + const btn = screen.getByTestId("btn") + fireEvent.click(btn) + + // Tracker should have been called + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith("button_click", { id: "123" }) + + // Existing handlers should still have been called + expect(existingCapture).toHaveBeenCalledTimes(1) + expect(existingClick).toHaveBeenCalledTimes(1) + }) + + it("should send event using render prop approach", () => { + const onEvent = vi.fn() + const existingClick = vi.fn() + + render( + + + {(track) => ( + + )} + + + ) + + const btn = screen.getByTestId("btn2") + fireEvent.click(btn) + + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith("custom_click", {}) + expect(existingClick).toHaveBeenCalledTimes(1) + }) + + it("should not crash or create extra dom nodes if children is text", () => { + const onEvent = vi.fn() + + const { container } = render( + + + Just some text + + + ) + + expect(container.innerHTML).toBe("Just some text") + }) + + it("should work with multiple children elements", () => { + const onEvent = vi.fn() + + render( + + +
One
+
Two
+
+
+ ) + + fireEvent.click(screen.getByTestId("multi1")) + expect(onEvent).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByTestId("multi2")) + expect(onEvent).toHaveBeenCalledTimes(2) + }) +}) From 04d8ceedd20aad9f5ecb4510f64f58d65b1e47d7 Mon Sep 17 00:00:00 2001 From: Dmitry Radkovskiy Date: Fri, 20 Mar 2026 09:13:12 +0000 Subject: [PATCH 5/8] refactor: update Track component props and replace child fn with render --- src/Track.tsx | 23 +++++++++++++++-------- tests/track-onclick.test.tsx | 30 ++++++++++++++---------------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/Track.tsx b/src/Track.tsx index 08f6e76..311eabb 100644 --- a/src/Track.tsx +++ b/src/Track.tsx @@ -4,17 +4,24 @@ import { EventObject } from "./types"; import { useTracker } from "./context"; import { parseEventArgs } from "./utils"; +type EventProps = { + event: string + params?: EventObject["params"] +} | { + event: EventObject +} + export const Track = { - OnMount: (props: EventObject & { children?: React.ReactNode }) => { - useMountEvent(props); + OnMount: (props: EventProps & { children?: React.ReactNode }) => { + useMountEvent(parseEventArgs(props.event)); return props.children ?? null; }, Impression: ({ children, options, - ...eventProps - }: EventObject & { + event: eventProps + }: EventProps & { children: React.ReactNode; options?: UseIntersectionObserverOptions; }) => { @@ -53,14 +60,14 @@ export const Track = { ); }, - OnClick: (props: EventObject & { children: React.ReactNode | ((track: () => void) => React.ReactNode) }) => { + OnClick: (props: EventProps & ({ children: React.ReactNode } | { render: (track: () => void) => React.ReactNode })) => { const { sendEvent } = useTracker(); - const { eventName, params } = parseEventArgs(props, undefined); + const { eventName, params } = parseEventArgs(props.event, undefined); const track = React.useCallback(() => sendEvent(eventName, params), [sendEvent, eventName, params]); - if (typeof props.children == "function") - return props.children(track); + if ("render" in props) + return props.render(track); return Children.map(props.children, child => { if (!isValidElement(child)) { diff --git a/tests/track-onclick.test.tsx b/tests/track-onclick.test.tsx index 5374651..a2fa36e 100644 --- a/tests/track-onclick.test.tsx +++ b/tests/track-onclick.test.tsx @@ -12,7 +12,7 @@ describe("Track.OnClick", () => { render( - + - )} - + ( + + )} /> ) @@ -71,7 +69,7 @@ describe("Track.OnClick", () => { const { container } = render( - + Just some text @@ -85,7 +83,7 @@ describe("Track.OnClick", () => { render( - +
One
Two
From 5e124f091d46963b1e8b959d7d5f024a8af0f095 Mon Sep 17 00:00:00 2001 From: Dmitry Radkovskiy Date: Fri, 20 Mar 2026 09:13:42 +0000 Subject: [PATCH 6/8] refactor: rename Impression component to OnImpression --- src/Track.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Track.tsx b/src/Track.tsx index 311eabb..17fe69c 100644 --- a/src/Track.tsx +++ b/src/Track.tsx @@ -17,7 +17,7 @@ export const Track = { return props.children ?? null; }, - Impression: ({ + OnImpression: ({ children, options, event: eventProps From 2af0f99a49f929f4f97893669e043f2b9d2aa792 Mon Sep 17 00:00:00 2001 From: Dmitry Radkovskiy Date: Fri, 20 Mar 2026 09:14:49 +0000 Subject: [PATCH 7/8] style: remove unnecessary braces for single-line if statements --- src/Track.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Track.tsx b/src/Track.tsx index 17fe69c..31ed287 100644 --- a/src/Track.tsx +++ b/src/Track.tsx @@ -70,16 +70,14 @@ export const Track = { return props.render(track); return Children.map(props.children, child => { - if (!isValidElement(child)) { + if (!isValidElement(child)) return child; - } return cloneElement(child as React.ReactElement, { onClickCapture: (e: React.MouseEvent) => { track(); - if (child.props && typeof child.props.onClickCapture === 'function') { + if (child.props && typeof child.props.onClickCapture === 'function') child.props.onClickCapture(e); - } } }); }); From 9e39e6ff5b0770b61067237a1e5b4d9702f2a42d Mon Sep 17 00:00:00 2001 From: Dmitry Radkovskiy Date: Fri, 20 Mar 2026 09:44:51 +0000 Subject: [PATCH 8/8] feat: add Track.OnChange component for tracking input changes --- src/Track.tsx | 42 ++++++++++++++++-- tests/Track.test.tsx | 26 +++++------ tests/track-oninput.test.tsx | 84 ++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 tests/track-oninput.test.tsx diff --git a/src/Track.tsx b/src/Track.tsx index 31ed287..5cc5434 100644 --- a/src/Track.tsx +++ b/src/Track.tsx @@ -11,22 +11,30 @@ type EventProps = { event: EventObject } +function parseEventProps(props: EventProps) +{ + if ("params" in props) + return parseEventArgs(props.event, props.params) + + return parseEventArgs(props.event) +} + export const Track = { OnMount: (props: EventProps & { children?: React.ReactNode }) => { - useMountEvent(parseEventArgs(props.event)); + useMountEvent(parseEventProps(props)); return props.children ?? null; }, OnImpression: ({ children, options, - event: eventProps + ...props }: EventProps & { children: React.ReactNode; options?: UseIntersectionObserverOptions; }) => { const { sendEvent } = useTracker(); - const { eventName, params } = parseEventArgs(eventProps, undefined); + const { eventName, params } = parseEventProps(props); // Keep track if we've already tracked this to handle freezeOnceVisible manually // because the ref callback might be called multiple times during renders @@ -60,9 +68,35 @@ export const Track = { ); }, + OnChange: (props: EventProps & { mapValue?: (value: string, e: React.ChangeEvent) => Record | undefined | void } & ({ children: React.ReactNode } | { render: (track: (e: React.ChangeEvent) => void) => React.ReactNode })) => { + const { sendEvent } = useTracker(); + const { eventName, params } = parseEventProps(props); + + const track = React.useCallback((e: React.ChangeEvent) => { + const mappedParams = props.mapValue?.(e.target.value, e) + sendEvent(eventName, mappedParams ? { ...params, ...mappedParams } : params); + }, [sendEvent, eventName, params, props.mapValue]); + + if ("render" in props) + return props.render(track); + + return Children.map(props.children, child => { + if (!isValidElement(child)) + return child; + + return cloneElement(child as React.ReactElement, { + onChangeCapture: (e: React.ChangeEvent) => { + track(e); + if (child.props && typeof child.props.onChangeCapture === 'function') + child.props.onChangeCapture(e); + } + }); + }); + }, + OnClick: (props: EventProps & ({ children: React.ReactNode } | { render: (track: () => void) => React.ReactNode })) => { const { sendEvent } = useTracker(); - const { eventName, params } = parseEventArgs(props.event, undefined); + const { eventName, params } = parseEventProps(props); const track = React.useCallback(() => sendEvent(eventName, params), [sendEvent, eventName, params]); diff --git a/tests/Track.test.tsx b/tests/Track.test.tsx index ae506e4..70aa2a1 100644 --- a/tests/Track.test.tsx +++ b/tests/Track.test.tsx @@ -21,7 +21,7 @@ const setupIntersectionObserverMock = () => { return IntersectionObserverMock } -describe("Track.Impression (deeply mocked, not very useful)", () => { +describe("Track.OnImpression (deeply mocked, not very useful)", () => { beforeEach(() => { observers.length = 0 setupIntersectionObserverMock() @@ -44,9 +44,9 @@ describe("Track.Impression (deeply mocked, not very useful)", () => { render( - +
Test Banner
-
+
) @@ -64,9 +64,9 @@ describe("Track.Impression (deeply mocked, not very useful)", () => { render( - +
Test Banner
-
+
) @@ -80,9 +80,9 @@ describe("Track.Impression (deeply mocked, not very useful)", () => { render( - +
Test Banner
-
+
) @@ -98,9 +98,9 @@ describe("Track.Impression (deeply mocked, not very useful)", () => { render( - +
Test Banner
-
+
) @@ -116,9 +116,9 @@ describe("Track.Impression (deeply mocked, not very useful)", () => { const { container } = render( - + Test Banner - + ) @@ -135,9 +135,9 @@ describe("Track.Impression (deeply mocked, not very useful)", () => { render( - + Test Banner - + ) diff --git a/tests/track-oninput.test.tsx b/tests/track-oninput.test.tsx new file mode 100644 index 0000000..ebb7a04 --- /dev/null +++ b/tests/track-oninput.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { TrackRoot } from "../src/context" +import { Track } from "../src/Track" + +describe("Track.OnChange", () => { + it("should send event when input is changed and preserve existing onChangeCapture", () => { + const onEvent = vi.fn() + const existingCapture = vi.fn() + const existingChange = vi.fn() + + render( + + + + + + ) + + const inp = screen.getByTestId("inp") + fireEvent.change(inp, { target: { value: "test" } }) + + // Tracker should have been called + expect(onEvent).toHaveBeenCalledTimes(1) + // By default it doesn't merge the value + expect(onEvent).toHaveBeenCalledWith("input_change", { id: "123" }) + + // Existing handlers should still have been called + expect(existingCapture).toHaveBeenCalledTimes(1) + expect(existingChange).toHaveBeenCalledTimes(1) + }) + + it("should merge mapped value when mapValue is provided", () => { + const onEvent = vi.fn() + + render( + + ({ query: val })} + > + + + + ) + + const inp = screen.getByTestId("inp") + fireEvent.change(inp, { target: { value: "hello" } }) + + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith("input_change", { field: "search", query: "hello" }) + }) + + it("should send event using render prop approach", () => { + const onEvent = vi.fn() + const existingChange = vi.fn() + + render( + + ( + { + track(e) + existingChange() + }} + /> + )} /> + + ) + + const inp = screen.getByTestId("inp2") + fireEvent.change(inp, { target: { value: "custom" } }) + + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith("custom_input", {}) + expect(existingChange).toHaveBeenCalledTimes(1) + }) +})