Skip to content

Commit 51a4793

Browse files
bkboothclaude
andauthored
fix(pixel): use ResizeObserver to detect above-fold scroll state (SDK-275) (#2868)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 756ce14 commit 51a4793

5 files changed

Lines changed: 132 additions & 18 deletions

File tree

packages/audience/core/AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Agent notes — @imtbl/audience-core
2+
3+
Shared internals for the Audience SDKs. Two consumers worth knowing about:
4+
5+
- **`@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/**`.
6+
- **`@imtbl/audience-sdk`** consumes this package normally as a workspace dep.
7+
8+
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.

packages/audience/pixel/AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Agent notes — @imtbl/pixel
2+
3+
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.**
4+
5+
- Budget lives in [`bundlebudget.json`](./bundlebudget.json) (gzipped).
6+
- 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.
7+
- `@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.

packages/audience/pixel/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@imtbl/pixel",
33
"description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data",
4-
"version": "0.1.0",
4+
"version": "0.1.1",
55
"author": "Immutable",
66
"private": true,
77
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",

packages/audience/pixel/src/autocapture.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ afterAll(() => {
3232
});
3333
});
3434

35+
// jsdom doesn't implement ResizeObserver — provide a minimal no-op stub so any
36+
// test that calls setupAutocapture without a specialised mock still works.
37+
// The scroll depth describe block overrides this with a controllable mock.
38+
(global as Record<string, unknown>).ResizeObserver = class {
39+
// eslint-disable-next-line class-methods-use-this
40+
observe() { /* no-op */ }
41+
42+
// eslint-disable-next-line class-methods-use-this
43+
disconnect() { /* no-op */ }
44+
};
45+
3546
describe('autocapture', () => {
3647
let enqueue: jest.Mock;
3748
let consent: ConsentLevel;
@@ -551,11 +562,19 @@ describe('autocapture', () => {
551562
let rafCallbacks: Array<() => void>;
552563
let originalRAF: typeof requestAnimationFrame;
553564
let originalCAF: typeof cancelAnimationFrame;
565+
let resizeCallback: (() => void) | null;
566+
let originalResizeObserver: typeof ResizeObserver;
567+
568+
function fireResizeObserver() {
569+
resizeCallback?.();
570+
}
554571

555572
beforeEach(() => {
556573
rafCallbacks = [];
557574
originalRAF = window.requestAnimationFrame;
558575
originalCAF = window.cancelAnimationFrame;
576+
resizeCallback = null;
577+
originalResizeObserver = (global as Record<string, unknown>).ResizeObserver as typeof ResizeObserver;
559578

560579
// Mock rAF: collect callbacks, flush manually
561580
let nextId = 1;
@@ -565,11 +584,26 @@ describe('autocapture', () => {
565584
return id;
566585
});
567586
window.cancelAnimationFrame = jest.fn();
587+
588+
// Mock ResizeObserver. Real RO delivers entries on a microtask/rAF; this
589+
// mock fires synchronously inside observe() and via fireResizeObserver()
590+
// for subsequent triggers. Outcome is equivalent for all current cases,
591+
// but tests won't catch ordering bugs that depend on the async boundary.
592+
(global as Record<string, unknown>).ResizeObserver = class MockResizeObserver {
593+
constructor(cb: () => void) { resizeCallback = cb; }
594+
595+
// eslint-disable-next-line class-methods-use-this
596+
observe() { resizeCallback?.(); }
597+
598+
// eslint-disable-next-line class-methods-use-this
599+
disconnect() { /* no-op */ }
600+
};
568601
});
569602

570603
afterEach(() => {
571604
window.requestAnimationFrame = originalRAF;
572605
window.cancelAnimationFrame = originalCAF;
606+
(global as Record<string, unknown>).ResizeObserver = originalResizeObserver;
573607
});
574608

575609
function flushRAF() {
@@ -780,6 +814,37 @@ describe('autocapture', () => {
780814

781815
expect(enqueue).not.toHaveBeenCalled();
782816
});
817+
818+
it('cancels dwell timer when page grows beyond viewport', () => {
819+
setup({ scroll: true });
820+
821+
// Sanity check: the dwell timer was actually scheduled (otherwise the
822+
// assertion below passes vacuously). Advance partway, no fire yet.
823+
jest.advanceTimersByTime(1000);
824+
expect(enqueue).not.toHaveBeenCalled();
825+
826+
// Simulate content loading and page growing.
827+
setScrollGeometry(2000, 600, 0);
828+
fireResizeObserver();
829+
830+
// Advance well past dwell time — no above-fold event should fire.
831+
jest.advanceTimersByTime(5000);
832+
expect(enqueue).not.toHaveBeenCalled();
833+
});
834+
835+
it('does not throw or attach observer when ResizeObserver is unavailable', () => {
836+
const original = (global as Record<string, unknown>).ResizeObserver;
837+
delete (global as Record<string, unknown>).ResizeObserver;
838+
839+
try {
840+
expect(() => setup({ scroll: true })).not.toThrow();
841+
// No above-fold event ever fires (no observer to start the timer).
842+
jest.advanceTimersByTime(2000);
843+
expect(enqueue).not.toHaveBeenCalled();
844+
} finally {
845+
(global as Record<string, unknown>).ResizeObserver = original;
846+
}
847+
});
783848
});
784849

785850
describe('configuration', () => {

packages/audience/pixel/src/autocapture.ts

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ function setupScrollTracking(
8989
const fired = new Set<number>();
9090
let rafId = 0;
9191
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
92+
let ro: ResizeObserver | null = null;
9293

9394
const checkAndFire = (): void => {
9495
if (!canTrack(getConsent())) return;
@@ -103,27 +104,56 @@ function setupScrollTracking(
103104
}
104105
};
105106

106-
const isAboveFold = document.documentElement.scrollHeight <= window.innerHeight;
107-
108-
if (isAboveFold) {
109-
// All content visible — fire a single depth: 100 after dwell time.
110-
// We deliberately skip intermediate milestones: the user didn't scroll
111-
// to 25/50/75, the content was simply short enough to fit.
112-
dwellTimer = setTimeout(() => {
113-
dwellTimer = null;
114-
if (!canTrack(getConsent())) return;
115-
if (!fired.has(100)) {
116-
fired.add(100);
117-
enqueue('scroll_depth', { depth: 100, aboveFold: true });
107+
// ResizeObserver manages the above-fold dwell timer reactively, so the
108+
// "is this page above-fold?" decision is never locked in at init time.
109+
// It fires on initial observe() and again whenever the document height
110+
// changes (images load, JS renders content, fonts swap, etc.).
111+
// Feature-detected: ~3% of supported browsers lack ResizeObserver. On those
112+
// we skip the synthetic above-fold event entirely rather than throwing and
113+
// breaking the rest of autocapture (forms, clicks).
114+
if (typeof ResizeObserver !== 'undefined') {
115+
ro = new ResizeObserver(() => {
116+
const nowAboveFold = document.documentElement.scrollHeight <= window.innerHeight;
117+
if (nowAboveFold) {
118+
if (dwellTimer === null && !fired.has(100)) {
119+
// All content fits in the viewport — start the dwell timer.
120+
// We deliberately skip intermediate milestones: the user didn't scroll
121+
// to 25/50/75, the content was simply short enough to fit.
122+
dwellTimer = setTimeout(() => {
123+
dwellTimer = null;
124+
if (!canTrack(getConsent())) return;
125+
if (fired.has(100)) return;
126+
// Defense-in-depth: real ResizeObserver delivery is batched to a
127+
// microtask, so a height change racing with the timer firing is
128+
// theoretically possible. Re-check before emitting.
129+
if (document.documentElement.scrollHeight > window.innerHeight) return;
130+
fired.add(100);
131+
enqueue('scroll_depth', { depth: 100, aboveFold: true });
132+
// After the synthetic event fires we deliberately leave the scroll
133+
// listener attached. If the page later grows and the user scrolls,
134+
// intermediate milestones (25/50/75/90) may also fire — we treat
135+
// that as legitimate engagement signal rather than suppressing it.
136+
ro?.disconnect();
137+
ro = null;
138+
}, ABOVE_FOLD_DWELL_MS);
139+
}
140+
} else if (dwellTimer !== null) {
141+
// Page grew beyond the viewport — cancel the above-fold path and let
142+
// the scroll listener fire milestones naturally as the user scrolls.
143+
clearTimeout(dwellTimer);
144+
dwellTimer = null;
118145
}
119-
}, ABOVE_FOLD_DWELL_MS);
120-
} else {
121-
// Check initial scroll position (e.g. anchor links, restored scroll).
146+
});
147+
148+
ro.observe(document.documentElement);
149+
}
150+
151+
// For scrollable pages: check if the user already scrolled past a milestone
152+
// before our listener attached (e.g. anchor links, restored scroll position).
153+
if (document.documentElement.scrollHeight > window.innerHeight) {
122154
checkAndFire();
123155
}
124156

125-
// Scroll listener (handles both scrollable pages and pages that become
126-
// scrollable after dynamic content loads).
127157
const onScroll = (): void => {
128158
if (rafId) return; // Already scheduled
129159
rafId = requestAnimationFrame(() => {
@@ -138,6 +168,10 @@ function setupScrollTracking(
138168
window.removeEventListener('scroll', onScroll);
139169
if (rafId) cancelAnimationFrame(rafId);
140170
if (dwellTimer !== null) clearTimeout(dwellTimer);
171+
if (ro) {
172+
ro.disconnect();
173+
ro = null;
174+
}
141175
};
142176
}
143177

0 commit comments

Comments
 (0)