diff --git a/src/Track.tsx b/src/Track.tsx new file mode 100644 index 0000000..5cc5434 --- /dev/null +++ b/src/Track.tsx @@ -0,0 +1,119 @@ +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"; + +type EventProps = { + event: string + params?: EventObject["params"] +} | { + 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(parseEventProps(props)); + return props.children ?? null; + }, + + OnImpression: ({ + children, + options, + ...props + }: EventProps & { + children: React.ReactNode; + options?: UseIntersectionObserverOptions; + }) => { + const { sendEvent } = useTracker(); + 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 + 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}
+ ); + }, + + 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 } = parseEventProps(props); + + const track = React.useCallback(() => sendEvent(eventName, params), [sendEvent, eventName, params]); + + 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, { + onClickCapture: (e: React.MouseEvent) => { + track(); + if (child.props && typeof child.props.onClickCapture === 'function') + child.props.onClickCapture(e); + } + }); + }); + } +}; 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/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" diff --git a/tests/Track.test.tsx b/tests/Track.test.tsx new file mode 100644 index 0000000..70aa2a1 --- /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.OnImpression (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) + }) +}) diff --git a/tests/track-onclick.test.tsx b/tests/track-onclick.test.tsx new file mode 100644 index 0000000..a2fa36e --- /dev/null +++ b/tests/track-onclick.test.tsx @@ -0,0 +1,99 @@ +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( + + ( + + )} /> + + ) + + 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) + }) +}) 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) + }) +})