Skip to content
Open
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
30 changes: 19 additions & 11 deletions packages/shared/src/hooks/useFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { usePlusSubscription } from './usePlusSubscription';
import { LogEvent } from '../lib/log';
import { useLogContext } from '../contexts/LogContext';
import type { FeedAdTemplate } from '../lib/feed';
import { getAdSlotIndex } from '../lib/feed';
import { featureFeedAdTemplate } from '../lib/featureManagement';
import { cloudinaryPostImageCoverPlaceholder } from '../lib/image';
import { AD_PLACEHOLDER_SOURCE_ID } from '../lib/constants';
Expand Down Expand Up @@ -268,6 +269,13 @@ export default function useFeed<T>(
);

const { fetchAd } = useFetchAd();
// Per-mount random seed for ad jitter. Stable across re-renders/pagination
// (so ads don't visibly jump as new pages load) but varies across mounts and
// sessions, so the same user doesn't see ads in the same spots every visit.
const adJitterSeedRef = useRef<string>();
if (!adJitterSeedRef.current) {
adJitterSeedRef.current = Math.random().toString(36).slice(2);
}
const adsQuery = useInfiniteQuery<
Ad,
ClientError,
Expand Down Expand Up @@ -318,21 +326,20 @@ export default function useFeed<T>(
adTemplate?.adStart ??
featureFeedAdTemplate.defaultValue.default.adStart;
const adRepeat = adTemplate?.adRepeat ?? pageSize + 1;
const adJitter = adTemplate?.adJitter ?? 0;

const adPage = getAdSlotIndex({
index,
adStart,
adRepeat,
adJitter,
seed: adJitterSeedRef.current ?? '',
});

const adIndex = index - adStart; // 0-based index from adStart

// if adIndex is negative, it means we are not supposed to show ads yet based on adStart
if (adIndex < 0) {
return undefined;
}
const adMatch = adIndex % adRepeat === 0; // should ad be shown at this index based on adRepeat

if (!adMatch) {
if (adPage === undefined) {
return undefined;
}

const adPage = adIndex / adRepeat; // page number for ad

if (isLoading) {
return createPlaceholderItem(adPage);
}
Expand Down Expand Up @@ -365,6 +372,7 @@ export default function useFeed<T>(
isLoading,
adTemplate?.adStart,
adTemplate?.adRepeat,
adTemplate?.adJitter,
adsUpdatedAt,
pageSize,
],
Expand Down
134 changes: 134 additions & 0 deletions packages/shared/src/lib/feed.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { getAdSlotIndex } from './feed';

describe('getAdSlotIndex', () => {
const seed = '["feed","my-feed"]';

it('matches modulo math when adJitter is 0', () => {
const adStart = 2;
const adRepeat = 5;
const positions: number[] = [];
for (let index = 0; index < 40; index += 1) {
const n = getAdSlotIndex({ index, adStart, adRepeat, seed });
if (n !== undefined) {
positions.push(index);
}
}
expect(positions).toEqual([2, 7, 12, 17, 22, 27, 32, 37]);
});

it('returns undefined for indices before adStart (no jitter)', () => {
const adStart = 2;
const adRepeat = 5;
expect(
getAdSlotIndex({ index: 0, adStart, adRepeat, seed }),
).toBeUndefined();
expect(
getAdSlotIndex({ index: 1, adStart, adRepeat, seed }),
).toBeUndefined();
});

it('returns undefined for non-positive adRepeat', () => {
expect(
getAdSlotIndex({ index: 2, adStart: 2, adRepeat: 0, seed }),
).toBeUndefined();
});

it('never places the first ad before adStart, even with jitter', () => {
const adStart = 2;
const adRepeat = 5;
const adJitter = 3;
const seeds = Array.from({ length: 50 }, (_, i) => `["feed","user-${i}"]`);
seeds.forEach((s) => {
let firstHit: number | undefined;
for (let index = 0; index < adStart + adRepeat; index += 1) {
if (
getAdSlotIndex({ index, adStart, adRepeat, adJitter, seed: s }) !==
undefined
) {
firstHit = index;
break;
}
}
expect(firstHit).toBeDefined();
expect(firstHit).toBeGreaterThanOrEqual(adStart);
expect(firstHit).toBeLessThanOrEqual(adStart + adJitter);
});
});

it('keeps consecutive ad gaps within [adRepeat - J, adRepeat + J]', () => {
const adStart = 2;
const adRepeat = 5;
const adJitter = 3;
const hits: number[] = [];
for (let index = 0; index < 200; index += 1) {
if (
getAdSlotIndex({ index, adStart, adRepeat, adJitter, seed }) !==
undefined
) {
hits.push(index);
}
}
expect(hits.length).toBeGreaterThan(5);
for (let i = 1; i < hits.length; i += 1) {
const gap = hits[i] - hits[i - 1];
expect(gap).toBeGreaterThanOrEqual(adRepeat - adJitter);
expect(gap).toBeLessThanOrEqual(adRepeat + adJitter);
}
});

it('is deterministic for the same seed and slot index', () => {
const args = {
adStart: 2,
adRepeat: 5,
adJitter: 2,
seed,
};
const runA: Array<number | undefined> = [];
const runB: Array<number | undefined> = [];
for (let index = 0; index < 40; index += 1) {
runA.push(getAdSlotIndex({ index, ...args }));
runB.push(getAdSlotIndex({ index, ...args }));
}
expect(runA).toEqual(runB);
});

it('produces different positions for different seeds', () => {
const args = { adStart: 2, adRepeat: 5, adJitter: 2 };
const collect = (s: string): number[] => {
const hits: number[] = [];
for (let index = 0; index < 60; index += 1) {
if (getAdSlotIndex({ index, ...args, seed: s }) !== undefined) {
hits.push(index);
}
}
return hits;
};
const seedA = '["feed","user-a"]';
const seedB = '["feed","user-b"]';
expect(collect(seedA)).not.toEqual(collect(seedB));
});

it('always leaves at least one post between consecutive ads, regardless of jitter size', () => {
// adRepeat is small and jitter is intentionally huge — clamp must still
// guarantee `gap >= 2` so we never render `ad, ad` back-to-back.
const adStart = 2;
const adRepeat = 3;
const adJitter = 100;
const seeds = Array.from({ length: 25 }, (_, i) => `["feed","user-${i}"]`);
seeds.forEach((s) => {
const hits: number[] = [];
for (let index = 0; index < 200; index += 1) {
if (
getAdSlotIndex({ index, adStart, adRepeat, adJitter, seed: s }) !==
undefined
) {
hits.push(index);
}
}
expect(hits.length).toBeGreaterThan(5);
for (let i = 1; i < hits.length; i += 1) {
expect(hits[i] - hits[i - 1]).toBeGreaterThanOrEqual(2);
}
});
});
});
56 changes: 56 additions & 0 deletions packages/shared/src/lib/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,62 @@ export const getFeedName = (
export type FeedAdTemplate = {
adStart: number;
adRepeat?: number;
adJitter?: number;
};

/* eslint-disable no-bitwise -- intentional bitwise ops for FNV-1a hash */
const hashSeed = (key: string, n: number): number => {
let h = 2166136261 >>> 0;
const s = `${key}:${n}`;
for (let i = 0; i < s.length; i += 1) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h;
};
/* eslint-enable no-bitwise */

// Minimum index distance between two consecutive ads. A gap of 2 guarantees
// at least one post between any two ads, so we never render `ad, ad` back-to-back.
const MIN_AD_GAP = 2;

export const getAdSlotIndex = ({
index,
adStart,
adRepeat,
adJitter = 0,
seed,
}: {
index: number;
adStart: number;
adRepeat: number;
adJitter?: number;
seed: string;
}): number | undefined => {
if (adRepeat <= 0) {
return undefined;
}
if (index < adStart) {
return undefined;
}
// Clamp jitter so each gap stays >= MIN_AD_GAP. With per-gap symmetric jitter
// in [-J, +J], the minimum gap is `adRepeat - J`, so J must be <= adRepeat - MIN_AD_GAP.
const safeJitter = Math.max(0, Math.min(adJitter, adRepeat - MIN_AD_GAP));
// Walk slots forward from 0, applying one independent jitter per gap, until
// we either hit `index` or pass it. The first slot uses one-sided jitter
// (0..+J) so the first ad never lands before `adStart`.
let pos =
adStart + (safeJitter === 0 ? 0 : hashSeed(seed, 0) % (safeJitter + 1));
let n = 0;
while (pos < index) {
n += 1;
const offset =
safeJitter === 0
? 0
: (hashSeed(seed, n) % (safeJitter * 2 + 1)) - safeJitter;
pos += adRepeat + offset;
}
return pos === index ? n : undefined;
};

export function usePostLogEvent() {
Expand Down
Loading