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
106 changes: 106 additions & 0 deletions packages/react-native-sdk/src/hooks/useViewportTracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,112 @@ describe('useViewportTracking', () => {
})
})

describe('sticky dedupe by success', () => {
it('should emit sticky once after successful trackView for one rendered component', async () => {
const { useViewportTracking } = await import('./useViewportTracking')
const entry = createMockEntry('sticky-success-entry')

const personalization: SelectedPersonalization = {
experienceId: 'exp-sticky-success',
variantIndex: 1,
variants: { 'sticky-success-component': 'sticky-success-entry' },
sticky: true,
}

mockTrackView.mockResolvedValue({})

const { onLayout } = useViewportTracking({
entry,
personalization,
viewTimeMs: 100,
viewDurationUpdateIntervalMs: 200,
threshold: 0.5,
})

onLayout(createLayoutEvent())

rs.advanceTimersByTime(100)
await Promise.resolve()
rs.advanceTimersByTime(200)

expect(mockTrackView).toHaveBeenCalledTimes(2)
expect(getCallArg(0).sticky).toBe(true)
expect(getCallArg(1).sticky).toBeUndefined()
})

it('should retry sticky until trackView resolves with a value', async () => {
const { useViewportTracking } = await import('./useViewportTracking')
const entry = createMockEntry('sticky-retry-entry')

const personalization: SelectedPersonalization = {
experienceId: 'exp-sticky-retry',
variantIndex: 1,
variants: { 'sticky-retry-component': 'sticky-retry-entry' },
sticky: true,
}

mockTrackView.mockResolvedValueOnce(undefined).mockResolvedValueOnce({}).mockResolvedValue({})

const { onLayout } = useViewportTracking({
entry,
personalization,
viewTimeMs: 100,
viewDurationUpdateIntervalMs: 200,
threshold: 0.5,
})

onLayout(createLayoutEvent())

rs.advanceTimersByTime(100)
await Promise.resolve()
rs.advanceTimersByTime(200)
await Promise.resolve()
rs.advanceTimersByTime(200)

expect(mockTrackView).toHaveBeenCalledTimes(3)
expect(getCallArg(0).sticky).toBe(true)
expect(getCallArg(1).sticky).toBe(true)
expect(getCallArg(2).sticky).toBeUndefined()
})

it('should dedupe sticky independently per rendered component instance', async () => {
const { useViewportTracking } = await import('./useViewportTracking')
const entry = createMockEntry('sticky-shared-entry')

const personalization: SelectedPersonalization = {
experienceId: 'exp-sticky-shared',
variantIndex: 1,
variants: { 'sticky-shared-component': 'sticky-shared-entry' },
sticky: true,
}

mockTrackView.mockResolvedValue({})

const first = useViewportTracking({
entry,
personalization,
viewTimeMs: 100,
threshold: 0.5,
})

const second = useViewportTracking({
entry,
personalization,
viewTimeMs: 100,
threshold: 0.5,
})

first.onLayout(createLayoutEvent())
second.onLayout(createLayoutEvent())

rs.advanceTimersByTime(100)

expect(mockTrackView).toHaveBeenCalledTimes(2)
expect(getCallArg(0).sticky).toBe(true)
expect(getCallArg(1).sticky).toBe(true)
})
})

describe('default options', () => {
it('should default threshold to 0.8, viewTimeMs to 2000, viewDurationUpdateIntervalMs to 5000', async () => {
const { useViewportTracking } = await import('./useViewportTracking')
Expand Down
57 changes: 49 additions & 8 deletions packages/react-native-sdk/src/hooks/useViewportTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function extractTrackingMetadata(
componentId: string
experienceId?: string
variantIndex: number
sticky?: boolean
} {
if (personalization) {
const componentId = Object.keys(personalization.variants).find(
Expand All @@ -155,13 +156,15 @@ export function extractTrackingMetadata(
componentId: componentId ?? resolvedEntry.sys.id,
experienceId: personalization.experienceId,
variantIndex: personalization.variantIndex,
sticky: personalization.sticky,
}
}

return {
componentId: resolvedEntry.sys.id,
experienceId: undefined,
variantIndex: 0,
sticky: undefined,
}
}

Expand Down Expand Up @@ -233,7 +236,7 @@ export function useViewportTracking({

const scrollContext = useScrollContext()

const { componentId, experienceId, variantIndex } = extractTrackingMetadata(
const { componentId, experienceId, variantIndex, sticky } = extractTrackingMetadata(
entry,
personalization,
)
Expand Down Expand Up @@ -266,6 +269,21 @@ export function useViewportTracking({
experienceIdRef.current = experienceId
const variantIndexRef = useRef(variantIndex)
variantIndexRef.current = variantIndex
const stickyRef = useRef(sticky)
stickyRef.current = sticky
const stickySuccessRef = useRef(false)
const stickyInFlightRef = useRef(false)
const stickyIdentityRef = useRef<string | null>(null)

const stickyIdentity = `${componentId}::${experienceId ?? ''}::${variantIndex}::${sticky === true ? '1' : '0'}`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should probably be extracted to a function or made cleaner somehow

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What standard should we follow to ensure such a line would be "clean"? To me it appears to be a specially-formatted disambiguation ID, assembled using string interpolation, that would only add more boilerplate to move to a function.


useEffect(() => {
if (stickyIdentityRef.current === stickyIdentity) return

stickyIdentityRef.current = stickyIdentity
stickySuccessRef.current = false
stickyInFlightRef.current = false
}, [stickyIdentity])

logger.debug(
`Hook initialized for ${componentId} (experienceId: ${experienceId}, variantIndex: ${variantIndex})`,
Expand Down Expand Up @@ -299,14 +317,37 @@ export function useViewportTracking({
`Emitting view event #${cycle.attempts} for ${componentIdRef.current} (viewDurationMs=${durationMs}, viewId=${viewId})`,
)

const shouldSendSticky =
stickyRef.current === true && !stickySuccessRef.current && !stickyInFlightRef.current

if (shouldSendSticky) {
stickyInFlightRef.current = true
}

void (async () => {
await optimizationRef.current.trackView({
componentId: componentIdRef.current,
viewId,
experienceId: experienceIdRef.current,
variantIndex: variantIndexRef.current,
viewDurationMs: durationMs,
})
try {
const data = await optimizationRef.current.trackView({
componentId: componentIdRef.current,
viewId,
experienceId: experienceIdRef.current,
variantIndex: variantIndexRef.current,
viewDurationMs: durationMs,
sticky: shouldSendSticky ? true : undefined,
})

if (shouldSendSticky && data !== undefined) {
stickySuccessRef.current = true
}
} catch (error) {
logger.error(
`Failed to emit view event for ${componentIdRef.current} (viewId=${viewId})`,
error,
)
} finally {
if (shouldSendSticky) {
stickyInFlightRef.current = false
}
}
})()
}, [])

Expand Down
6 changes: 5 additions & 1 deletion packages/universal/core-sdk/src/CoreBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,15 @@ abstract class CoreBase implements ResolverMethods {
async trackView(
payload: ViewBuilderArgs & { profile?: PartialProfile },
): Promise<OptimizationData | undefined> {
let result = undefined

if (payload.sticky) {
return await this._personalization.trackView(payload)
result = await this._personalization.trackView(payload)
}

await this._analytics.trackView(payload)

return result
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface CreateTimedEntryDetectorOptions<
core: TCore,
payload: NonNullable<ReturnType<typeof resolveTrackingPayload>>,
info: TInfo,
element: Element,
) => Promise<void>
}

Expand Down Expand Up @@ -82,7 +83,7 @@ export function createTimedEntryDetector<
const payload = resolveTrackingPayload(info.data, element)
if (!payload) return

await track(runtimeCore, payload, info)
await track(runtimeCore, payload, info, element)
}

let observer: TObserver | undefined = undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,152 @@ describe('EntryViewTracker', () => {
cleanup()
})

it('emits sticky only once per element across visibility cycles after success', async () => {
const entry = document.createElement('div')
entry.dataset.ctflEntryId = 'entry-sticky-once'
entry.dataset.ctflSticky = 'true'
document.body.append(entry)

const stickySuccess = {}
const trackView = rs.fn().mockResolvedValue(stickySuccess)
const core: EntryViewTrackingCore = { trackView }
const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core))

tracker.start({ dwellTimeMs: 0, viewDurationUpdateIntervalMs: 10_000 })

const instance = io.getLast()

if (!instance) {
throw new Error('IntersectionObserver polyfill instance not found')
}

instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 })
await advance(0)
instance.trigger({ target: entry, isIntersecting: false, intersectionRatio: 0 })
await Promise.resolve()
instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 })
await advance(0)

expect(trackView).toHaveBeenCalledTimes(2)
expect(trackView.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
componentId: 'entry-sticky-once',
sticky: true,
}),
)
expect(trackView.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
componentId: 'entry-sticky-once',
sticky: undefined,
}),
)

cleanup()
})

it('retries sticky for the same element until a successful personalization response', async () => {
const entry = document.createElement('div')
entry.dataset.ctflEntryId = 'entry-sticky-retry'
entry.dataset.ctflSticky = 'true'
document.body.append(entry)

const stickySuccess = {}
const trackView = rs
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(stickySuccess)
.mockResolvedValue(stickySuccess)
const core: EntryViewTrackingCore = { trackView }
const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core))

tracker.start({ dwellTimeMs: 0, viewDurationUpdateIntervalMs: 10_000 })

const instance = io.getLast()

if (!instance) {
throw new Error('IntersectionObserver polyfill instance not found')
}

instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 })
await advance(0)
instance.trigger({ target: entry, isIntersecting: false, intersectionRatio: 0 })
await Promise.resolve()
instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 })
await advance(0)
instance.trigger({ target: entry, isIntersecting: false, intersectionRatio: 0 })
await Promise.resolve()
instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 })
await advance(0)

expect(trackView).toHaveBeenCalledTimes(3)
expect(trackView.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
componentId: 'entry-sticky-retry',
sticky: true,
}),
)
expect(trackView.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
componentId: 'entry-sticky-retry',
sticky: true,
}),
)
expect(trackView.mock.calls[2]?.[0]).toEqual(
expect.objectContaining({
componentId: 'entry-sticky-retry',
sticky: undefined,
}),
)

cleanup()
})

it('treats separately rendered elements as distinct sticky dedupe targets', async () => {
const first = document.createElement('div')
first.dataset.ctflEntryId = 'entry-shared-component-id'
first.dataset.ctflSticky = 'true'
document.body.append(first)

const second = document.createElement('div')
second.dataset.ctflEntryId = 'entry-shared-component-id'
second.dataset.ctflSticky = 'true'
document.body.append(second)

const stickySuccess = {}
const trackView = rs.fn().mockResolvedValue(stickySuccess)
const core: EntryViewTrackingCore = { trackView }
const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core))

tracker.start({ dwellTimeMs: 0, viewDurationUpdateIntervalMs: 10_000 })

const instance = io.getLast()

if (!instance) {
throw new Error('IntersectionObserver polyfill instance not found')
}

instance.trigger({ target: first, isIntersecting: true, intersectionRatio: 1 })
await advance(0)
instance.trigger({ target: second, isIntersecting: true, intersectionRatio: 1 })
await advance(0)

expect(trackView).toHaveBeenCalledTimes(2)
expect(trackView.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
componentId: 'entry-shared-component-id',
sticky: true,
}),
)
expect(trackView.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
componentId: 'entry-shared-component-id',
sticky: true,
}),
)

cleanup()
})

it('emits a final duration update when an observed entry leaves view', async () => {
const entry = document.createElement('div')
entry.dataset.ctflEntryId = 'entry-view-final'
Expand Down
Loading
Loading