diff --git a/packages/audience/core/AGENTS.md b/packages/audience/core/AGENTS.md new file mode 100644 index 0000000000..07462ff4be --- /dev/null +++ b/packages/audience/core/AGENTS.md @@ -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. diff --git a/packages/audience/pixel/AGENTS.md b/packages/audience/pixel/AGENTS.md new file mode 100644 index 0000000000..3f48973a4a --- /dev/null +++ b/packages/audience/pixel/AGENTS.md @@ -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. diff --git a/packages/audience/pixel/package.json b/packages/audience/pixel/package.json index 11fd50e909..74b4400ad5 100644 --- a/packages/audience/pixel/package.json +++ b/packages/audience/pixel/package.json @@ -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", diff --git a/packages/audience/pixel/src/autocapture.test.ts b/packages/audience/pixel/src/autocapture.test.ts index b28b68c19d..63dcc0097f 100644 --- a/packages/audience/pixel/src/autocapture.test.ts +++ b/packages/audience/pixel/src/autocapture.test.ts @@ -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).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; @@ -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).ResizeObserver as typeof ResizeObserver; // Mock rAF: collect callbacks, flush manually let nextId = 1; @@ -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).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).ResizeObserver = originalResizeObserver; }); function flushRAF() { @@ -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).ResizeObserver; + delete (global as Record).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).ResizeObserver = original; + } + }); }); describe('configuration', () => { diff --git a/packages/audience/pixel/src/autocapture.ts b/packages/audience/pixel/src/autocapture.ts index 48287a9b22..92608c2278 100644 --- a/packages/audience/pixel/src/autocapture.ts +++ b/packages/audience/pixel/src/autocapture.ts @@ -89,6 +89,7 @@ function setupScrollTracking( const fired = new Set(); let rafId = 0; let dwellTimer: ReturnType | null = null; + let ro: ResizeObserver | null = null; const checkAndFire = (): void => { if (!canTrack(getConsent())) return; @@ -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(() => { @@ -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; + } }; }