Skip to content
119 changes: 119 additions & 0 deletions src/Track.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(hasRef ? [(child as any).ref, impressionRef] : [impressionRef]);

return hasRef ? (
cloneElement(child as any, { ref })
) : (
<div ref={ref}>{child}</div>
);
},

OnChange: (props: EventProps & { mapValue?: (value: string, e: React.ChangeEvent<any>) => Record<string, any> | undefined | void } & ({ children: React.ReactNode } | { render: (track: (e: React.ChangeEvent<any>) => void) => React.ReactNode })) => {
const { sendEvent } = useTracker();
const { eventName, params } = parseEventProps(props);

const track = React.useCallback((e: React.ChangeEvent<any>) => {
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<any>) => {
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);
}
});
});
}
};
4 changes: 3 additions & 1 deletion src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./useMountEvent"
export * from "./useMountEvent"
export * from "./useIntersectionObserver"
export * from "./useMergeRefs"
83 changes: 83 additions & 0 deletions src/hooks/useIntersectionObserver.ts
Original file line number Diff line number Diff line change
@@ -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<Element | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const frozenRef = useRef(false);

const callbackRef = useRef<UseIntersectionObserverOptions["onChange"]>(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 };
}
20 changes: 20 additions & 0 deletions src/hooks/useMergeRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useMemo, Ref, RefCallback } from "react";

export function useMergeRefs<Instance>(refs: Array<Ref<Instance> | undefined>): RefCallback<Instance> | 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<Instance | null>).current = value;
}
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, refs);
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./context"
export * from "./hooks"
export * from "./hooks"
export * from "./Track"
151 changes: 151 additions & 0 deletions tests/Track.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TrackRoot onEvent={onEvent}>
<Track.OnImpression event="banner_view">
<div>Test Banner</div>
</Track.OnImpression>
</TrackRoot>
)

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(
<TrackRoot onEvent={onEvent}>
<Track.OnImpression event="banner_view" params={params}>
<div>Test Banner</div>
</Track.OnImpression>
</TrackRoot>
)

triggerIntersection(true)

expect(onEvent).toHaveBeenCalledWith("banner_view", params)
})

it("should only send event once by default (freezeOnceVisible: true)", () => {
const onEvent = vi.fn()

render(
<TrackRoot onEvent={onEvent}>
<Track.OnImpression event="banner_view">
<div>Test Banner</div>
</Track.OnImpression>
</TrackRoot>
)

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(
<TrackRoot onEvent={onEvent}>
<Track.OnImpression event="banner_view" options={{ freezeOnceVisible: false }}>
<div>Test Banner</div>
</Track.OnImpression>
</TrackRoot>
)

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(
<TrackRoot onEvent={onEvent}>
<Track.OnImpression event="banner_view">
<span>Test Banner</span>
</Track.OnImpression>
</TrackRoot>
)

expect(container.innerHTML).toBe('<div><span>Test Banner</span></div>')
})

it("should merge refs for children that already have a ref", () => {
const onEvent = vi.fn()
const innerRef = vi.fn()

const ComponentWithRef = forwardRef<HTMLDivElement, { children: React.ReactNode }>(
({ children }, ref) => <div ref={ref} data-testid="inner">{children}</div>
)

render(
<TrackRoot onEvent={onEvent}>
<Track.OnImpression event="banner_view">
<ComponentWithRef ref={innerRef}>Test Banner</ComponentWithRef>
</Track.OnImpression>
</TrackRoot>
)

// 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)
})
})
Loading