Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/audience/core/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Agent notes — @imtbl/audience-core

Shared internals for the Audience SDKs. Two consumers worth knowing about:

- **`@imtbl/pixel`** bundles this package **inline** into a CDN snippet with a strict size budget. Adding code, dependencies, or large constants here can push the pixel over budget — see [`packages/audience/pixel/AGENTS.md`](../pixel/AGENTS.md). CI runs the pixel bundle-size check on every PR touching `core/**`.
- **`@imtbl/audience-sdk`** consumes this package normally as a workspace dep.

Prefer narrow, tree-shakeable exports. A helper that's only imported by the SDK still costs the pixel bundle bytes if it's reachable from a shared module graph.
7 changes: 7 additions & 0 deletions packages/audience/pixel/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Agent notes — @imtbl/pixel

This package ships a third-party tracking snippet served from `cdn.immutable.com` and embedded on customer sites. **Bundle size is a hard product constraint.**

- Budget lives in [`bundlebudget.json`](./bundlebudget.json) (gzipped).
- CI enforces it on every PR touching `packages/audience/pixel/**` or `packages/audience/core/**` via [`.github/workflows/pixel-bundle-size.yaml`](../../../.github/workflows/pixel-bundle-size.yaml) — builds base vs. head, posts a delta comment, fails over budget. Local rebuilds (`pnpm build` then `gzip -c dist/imtbl.js | wc -c`) are useful for fast iteration while you're cutting bytes, but the workflow is the source of truth.
- `@imtbl/audience-core` is **bundled inline** via the `tsup.config.ts` alias, not externalised. Changes to `core` count toward this budget — that's why the workflow triggers on `core/**` paths too.
2 changes: 1 addition & 1 deletion packages/audience/pixel/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@imtbl/pixel",
"description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data",
"version": "0.1.0",
"version": "0.1.1",
"author": "Immutable",
"private": true,
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
Expand Down
65 changes: 65 additions & 0 deletions packages/audience/pixel/src/autocapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ afterAll(() => {
});
});

// jsdom doesn't implement ResizeObserver — provide a minimal no-op stub so any
// test that calls setupAutocapture without a specialised mock still works.
// The scroll depth describe block overrides this with a controllable mock.
(global as Record<string, unknown>).ResizeObserver = class {
// eslint-disable-next-line class-methods-use-this
observe() { /* no-op */ }

// eslint-disable-next-line class-methods-use-this
disconnect() { /* no-op */ }
};

describe('autocapture', () => {
let enqueue: jest.Mock;
let consent: ConsentLevel;
Expand Down Expand Up @@ -551,11 +562,19 @@ describe('autocapture', () => {
let rafCallbacks: Array<() => void>;
let originalRAF: typeof requestAnimationFrame;
let originalCAF: typeof cancelAnimationFrame;
let resizeCallback: (() => void) | null;
let originalResizeObserver: typeof ResizeObserver;

function fireResizeObserver() {
resizeCallback?.();
}

beforeEach(() => {
rafCallbacks = [];
originalRAF = window.requestAnimationFrame;
originalCAF = window.cancelAnimationFrame;
resizeCallback = null;
originalResizeObserver = (global as Record<string, unknown>).ResizeObserver as typeof ResizeObserver;

// Mock rAF: collect callbacks, flush manually
let nextId = 1;
Expand All @@ -565,11 +584,26 @@ describe('autocapture', () => {
return id;
});
window.cancelAnimationFrame = jest.fn();

// Mock ResizeObserver. Real RO delivers entries on a microtask/rAF; this
// mock fires synchronously inside observe() and via fireResizeObserver()
// for subsequent triggers. Outcome is equivalent for all current cases,
// but tests won't catch ordering bugs that depend on the async boundary.
(global as Record<string, unknown>).ResizeObserver = class MockResizeObserver {
constructor(cb: () => void) { resizeCallback = cb; }

// eslint-disable-next-line class-methods-use-this
observe() { resizeCallback?.(); }

// eslint-disable-next-line class-methods-use-this
disconnect() { /* no-op */ }
};
});

afterEach(() => {
window.requestAnimationFrame = originalRAF;
window.cancelAnimationFrame = originalCAF;
(global as Record<string, unknown>).ResizeObserver = originalResizeObserver;
});

function flushRAF() {
Expand Down Expand Up @@ -780,6 +814,37 @@ describe('autocapture', () => {

expect(enqueue).not.toHaveBeenCalled();
});

it('cancels dwell timer when page grows beyond viewport', () => {
setup({ scroll: true });

// Sanity check: the dwell timer was actually scheduled (otherwise the
// assertion below passes vacuously). Advance partway, no fire yet.
jest.advanceTimersByTime(1000);
expect(enqueue).not.toHaveBeenCalled();

// Simulate content loading and page growing.
setScrollGeometry(2000, 600, 0);
fireResizeObserver();

// Advance well past dwell time — no above-fold event should fire.
jest.advanceTimersByTime(5000);
expect(enqueue).not.toHaveBeenCalled();
});

it('does not throw or attach observer when ResizeObserver is unavailable', () => {
const original = (global as Record<string, unknown>).ResizeObserver;
delete (global as Record<string, unknown>).ResizeObserver;

try {
expect(() => setup({ scroll: true })).not.toThrow();
// No above-fold event ever fires (no observer to start the timer).
jest.advanceTimersByTime(2000);
expect(enqueue).not.toHaveBeenCalled();
} finally {
(global as Record<string, unknown>).ResizeObserver = original;
}
});
});

describe('configuration', () => {
Expand Down
68 changes: 51 additions & 17 deletions packages/audience/pixel/src/autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function setupScrollTracking(
const fired = new Set<number>();
let rafId = 0;
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
let ro: ResizeObserver | null = null;

const checkAndFire = (): void => {
if (!canTrack(getConsent())) return;
Expand All @@ -103,27 +104,56 @@ function setupScrollTracking(
}
};

const isAboveFold = document.documentElement.scrollHeight <= window.innerHeight;

if (isAboveFold) {
// All content visible — fire a single depth: 100 after dwell time.
// We deliberately skip intermediate milestones: the user didn't scroll
// to 25/50/75, the content was simply short enough to fit.
dwellTimer = setTimeout(() => {
dwellTimer = null;
if (!canTrack(getConsent())) return;
if (!fired.has(100)) {
fired.add(100);
enqueue('scroll_depth', { depth: 100, aboveFold: true });
// ResizeObserver manages the above-fold dwell timer reactively, so the
// "is this page above-fold?" decision is never locked in at init time.
// It fires on initial observe() and again whenever the document height
// changes (images load, JS renders content, fonts swap, etc.).
// Feature-detected: ~3% of supported browsers lack ResizeObserver. On those
// we skip the synthetic above-fold event entirely rather than throwing and
// breaking the rest of autocapture (forms, clicks).
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => {
const nowAboveFold = document.documentElement.scrollHeight <= window.innerHeight;
if (nowAboveFold) {
if (dwellTimer === null && !fired.has(100)) {
// All content fits in the viewport — start the dwell timer.
// We deliberately skip intermediate milestones: the user didn't scroll
// to 25/50/75, the content was simply short enough to fit.
dwellTimer = setTimeout(() => {
dwellTimer = null;
if (!canTrack(getConsent())) return;
if (fired.has(100)) return;
// Defense-in-depth: real ResizeObserver delivery is batched to a
// microtask, so a height change racing with the timer firing is
// theoretically possible. Re-check before emitting.
if (document.documentElement.scrollHeight > window.innerHeight) return;
fired.add(100);
enqueue('scroll_depth', { depth: 100, aboveFold: true });
// After the synthetic event fires we deliberately leave the scroll
// listener attached. If the page later grows and the user scrolls,
// intermediate milestones (25/50/75/90) may also fire — we treat
// that as legitimate engagement signal rather than suppressing it.
ro?.disconnect();
ro = null;
}, ABOVE_FOLD_DWELL_MS);
}
} else if (dwellTimer !== null) {
// Page grew beyond the viewport — cancel the above-fold path and let
// the scroll listener fire milestones naturally as the user scrolls.
clearTimeout(dwellTimer);
dwellTimer = null;
}
}, ABOVE_FOLD_DWELL_MS);
} else {
// Check initial scroll position (e.g. anchor links, restored scroll).
});

ro.observe(document.documentElement);
}

// For scrollable pages: check if the user already scrolled past a milestone
// before our listener attached (e.g. anchor links, restored scroll position).
if (document.documentElement.scrollHeight > window.innerHeight) {
checkAndFire();
}

// Scroll listener (handles both scrollable pages and pages that become
// scrollable after dynamic content loads).
const onScroll = (): void => {
if (rafId) return; // Already scheduled
rafId = requestAnimationFrame(() => {
Expand All @@ -138,6 +168,10 @@ function setupScrollTracking(
window.removeEventListener('scroll', onScroll);
if (rafId) cancelAnimationFrame(rafId);
if (dwellTimer !== null) clearTimeout(dwellTimer);
if (ro) {
ro.disconnect();
ro = null;
}
};
}

Expand Down
Loading