Skip to content

Commit a5117c3

Browse files
authored
Merge pull request #12 from flashcatcloud/feat/replay-resnapshot-on-reactivation
fix(replay): re-snapshot on window/tab re-activation
2 parents 9b27f7d + 19f0f95 commit a5117c3

9 files changed

Lines changed: 193 additions & 2 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Configuration } from '../domain/configuration'
2+
import type { Clock } from '../../test'
3+
import { createNewEvent, setPageVisibility, restorePageVisibility, registerCleanupTask, mockClock } from '../../test'
4+
import { createPageActivationObservable, REACTIVATE_DEBOUNCE } from './pageActivationObservable'
5+
6+
describe('createPageActivationObservable', () => {
7+
let onActivateSpy: jasmine.Spy<() => void>
8+
let configuration: Configuration
9+
let clock: Clock
10+
11+
beforeEach(() => {
12+
onActivateSpy = jasmine.createSpy()
13+
configuration = {} as Configuration
14+
clock = mockClock()
15+
registerCleanupTask(createPageActivationObservable(configuration).subscribe(onActivateSpy).unsubscribe)
16+
registerCleanupTask(() => {
17+
restorePageVisibility()
18+
clock.cleanup()
19+
})
20+
})
21+
22+
it('notifies on focus after a blur', () => {
23+
window.dispatchEvent(createNewEvent('blur'))
24+
window.dispatchEvent(createNewEvent('focus'))
25+
26+
expect(onActivateSpy).toHaveBeenCalledTimes(1)
27+
})
28+
29+
it('does NOT notify on focus without a prior blur or hide', () => {
30+
window.dispatchEvent(createNewEvent('focus'))
31+
32+
expect(onActivateSpy).not.toHaveBeenCalled()
33+
})
34+
35+
it('notifies on visibilitychange visible after becoming hidden', () => {
36+
emulatePageVisibilityChange('hidden')
37+
emulatePageVisibilityChange('visible')
38+
39+
expect(onActivateSpy).toHaveBeenCalledTimes(1)
40+
})
41+
42+
it('notifies on pageshow after being inactive', () => {
43+
window.dispatchEvent(createNewEvent('blur'))
44+
window.dispatchEvent(createNewEvent('pageshow'))
45+
46+
expect(onActivateSpy).toHaveBeenCalledTimes(1)
47+
})
48+
49+
it('debounces two reactivations within the debounce window to one', () => {
50+
window.dispatchEvent(createNewEvent('blur'))
51+
window.dispatchEvent(createNewEvent('focus'))
52+
window.dispatchEvent(createNewEvent('blur'))
53+
window.dispatchEvent(createNewEvent('focus'))
54+
55+
expect(onActivateSpy).toHaveBeenCalledTimes(1)
56+
})
57+
58+
it('notifies twice for two genuine cycles spaced beyond the debounce window', () => {
59+
window.dispatchEvent(createNewEvent('blur'))
60+
window.dispatchEvent(createNewEvent('focus'))
61+
clock.tick(REACTIVATE_DEBOUNCE + 1)
62+
window.dispatchEvent(createNewEvent('blur'))
63+
window.dispatchEvent(createNewEvent('focus'))
64+
65+
expect(onActivateSpy).toHaveBeenCalledTimes(2)
66+
})
67+
68+
function emulatePageVisibilityChange(visibility: 'visible' | 'hidden') {
69+
setPageVisibility(visibility)
70+
document.dispatchEvent(createNewEvent('visibilitychange'))
71+
}
72+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Observable } from '../tools/observable'
2+
import type { Configuration } from '../domain/configuration'
3+
import { ONE_SECOND, timeStampNow } from '../tools/utils/timeUtils'
4+
import { addEventListeners, DOM_EVENT } from './addEventListener'
5+
6+
export const REACTIVATE_DEBOUNCE = ONE_SECOND
7+
8+
/**
9+
* Emits when the page transitions from an inactive (blurred / hidden) state back to active.
10+
*
11+
* Uses focus/blur as the primary signal because it is the only one that fires for Electron
12+
* multi-window switching, where document.visibilityState stays 'visible'. visibilitychange and
13+
* pageshow are added so browser-tab switches and bfcache restores are covered too. An `isInactive`
14+
* gate ensures only a genuine leave -> return cycle notifies (not the initial focus or an in-page
15+
* refocus), and a short debounce absorbs focus flicker / programmatic focus theft.
16+
*/
17+
export function createPageActivationObservable(configuration: Configuration): Observable<void> {
18+
return new Observable<void>((observable) => {
19+
let isInactive = false
20+
let lastActivationTimestamp = 0
21+
22+
const notifyActivation = () => {
23+
if (!isInactive) {
24+
return
25+
}
26+
isInactive = false
27+
const now = timeStampNow()
28+
if (now - lastActivationTimestamp < REACTIVATE_DEBOUNCE) {
29+
return
30+
}
31+
lastActivationTimestamp = now
32+
observable.notify()
33+
}
34+
35+
const { stop } = addEventListeners(
36+
configuration,
37+
window,
38+
[DOM_EVENT.BLUR, DOM_EVENT.FOCUS, DOM_EVENT.VISIBILITY_CHANGE, DOM_EVENT.PAGE_SHOW],
39+
(event) => {
40+
if (event.type === DOM_EVENT.BLUR) {
41+
isInactive = true
42+
} else if (event.type === DOM_EVENT.VISIBILITY_CHANGE) {
43+
if (document.visibilityState === 'hidden') {
44+
isInactive = true
45+
} else {
46+
notifyActivation()
47+
}
48+
} else {
49+
// focus or pageshow
50+
notifyActivation()
51+
}
52+
},
53+
{ capture: true }
54+
)
55+
56+
return stop
57+
})
58+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export type { FetchResolveContext, FetchStartContext, FetchContext } from './bro
112112
export { initFetchObservable, resetFetchObservable } from './browser/fetchObservable'
113113
export type { PageMayExitEvent } from './browser/pageMayExitObservable'
114114
export { createPageMayExitObservable, PageExitReason, isPageExitReason } from './browser/pageMayExitObservable'
115+
export { createPageActivationObservable } from './browser/pageActivationObservable'
115116
export * from './browser/addEventListener'
116117
export { requestIdleCallback } from './tools/requestIdleCallback'
117118
export * from './tools/taskQueue'

packages/rum-core/src/boot/startRum.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
import {
1010
sendToExtension,
1111
createPageMayExitObservable,
12+
createPageActivationObservable,
1213
TelemetryService,
1314
startTelemetry,
1415
canUseEventBridge,
@@ -106,6 +107,12 @@ export function startRum(
106107
})
107108
cleanupTasks.push(() => pageMayExitSubscription.unsubscribe())
108109

110+
const pageActivationObservable = createPageActivationObservable(configuration)
111+
const pageActivationSubscription = pageActivationObservable.subscribe(() => {
112+
lifeCycle.notify(LifeCycleEventType.PAGE_REACTIVATED)
113+
})
114+
cleanupTasks.push(() => pageActivationSubscription.unsubscribe())
115+
109116
const session = !canUseEventBridge()
110117
? startRumSessionManager(configuration, lifeCycle, trackingConsentState)
111118
: startRumSessionManagerStub()

packages/rum-core/src/domain/lifeCycle.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const enum LifeCycleEventType {
3333
SESSION_EXPIRED,
3434
SESSION_RENEWED,
3535
PAGE_MAY_EXIT,
36+
PAGE_REACTIVATED,
3637
RAW_RUM_EVENT_COLLECTED,
3738
RUM_EVENT_COLLECTED,
3839
RAW_ERROR_COLLECTED,
@@ -64,6 +65,7 @@ declare const LifeCycleEventTypeAsConst: {
6465
SESSION_EXPIRED: LifeCycleEventType.SESSION_EXPIRED
6566
SESSION_RENEWED: LifeCycleEventType.SESSION_RENEWED
6667
PAGE_MAY_EXIT: LifeCycleEventType.PAGE_MAY_EXIT
68+
PAGE_REACTIVATED: LifeCycleEventType.PAGE_REACTIVATED
6769
RAW_RUM_EVENT_COLLECTED: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED
6870
RUM_EVENT_COLLECTED: LifeCycleEventType.RUM_EVENT_COLLECTED
6971
RAW_ERROR_COLLECTED: LifeCycleEventType.RAW_ERROR_COLLECTED
@@ -84,6 +86,7 @@ export interface LifeCycleEventMap {
8486
[LifeCycleEventTypeAsConst.SESSION_EXPIRED]: void
8587
[LifeCycleEventTypeAsConst.SESSION_RENEWED]: void
8688
[LifeCycleEventTypeAsConst.PAGE_MAY_EXIT]: PageMayExitEvent
89+
[LifeCycleEventTypeAsConst.PAGE_REACTIVATED]: void
8790
[LifeCycleEventTypeAsConst.RAW_RUM_EVENT_COLLECTED]: RawRumEventCollectedData
8891
[LifeCycleEventTypeAsConst.RUM_EVENT_COLLECTED]: RumEvent & Context
8992
[LifeCycleEventTypeAsConst.RAW_ERROR_COLLECTED]: {

packages/rum/src/domain/record/startFullSnapshots.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ describe('startFullSnapshots', () => {
3737
expect(fullSnapshotCallback).toHaveBeenCalledTimes(2)
3838
})
3939

40+
it('takes a full snapshot when the page is re-activated', () => {
41+
lifeCycle.notify(LifeCycleEventType.PAGE_REACTIVATED)
42+
43+
expect(fullSnapshotCallback).toHaveBeenCalledTimes(2) // 1 on init + 1 on re-activation
44+
const records = fullSnapshotCallback.calls.mostRecent().args[0]
45+
expect(records.some((record) => record.type === RecordType.FullSnapshot)).toBe(true)
46+
})
47+
4048
it('full snapshot related records should have the view change date', () => {
4149
lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
4250
startClocks: viewStartClock,

packages/rum/src/domain/record/startFullSnapshots.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function startFullSnapshots(
6767

6868
fullSnapshotCallback(takeFullSnapshot())
6969

70-
const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => {
70+
const { unsubscribe: unsubscribeViewCreated } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => {
7171
flushMutations()
7272
fullSnapshotCallback(
7373
takeFullSnapshot(view.startClocks.timeStamp, {
@@ -78,7 +78,24 @@ export function startFullSnapshots(
7878
)
7979
})
8080

81+
// When the page is re-activated (window/tab switched back to), take a fresh full snapshot so the
82+
// new segment has its own baseline instead of inheriting another window/tab's snapshot. The
83+
// segment is flushed first by segmentCollection, which subscribes to PAGE_REACTIVATED earlier.
84+
const { unsubscribe: unsubscribeReactivated } = lifeCycle.subscribe(LifeCycleEventType.PAGE_REACTIVATED, () => {
85+
flushMutations()
86+
fullSnapshotCallback(
87+
takeFullSnapshot(timeStampNow(), {
88+
shadowRootsController,
89+
status: SerializationContextStatus.SUBSEQUENT_FULL_SNAPSHOT,
90+
elementsScrollPositions,
91+
})
92+
)
93+
})
94+
8195
return {
82-
stop: unsubscribe,
96+
stop: () => {
97+
unsubscribeViewCreated()
98+
unsubscribeReactivated()
99+
},
83100
}
84101
}

packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,23 @@ describe('startSegmentCollection', () => {
186186
})
187187
})
188188

189+
describe('flush when the page is re-activated', () => {
190+
function emulateReactivation() {
191+
lifeCycle.notify(LifeCycleEventType.PAGE_REACTIVATED)
192+
}
193+
194+
it('uses `httpRequest.send` when sending the segment', () => {
195+
addRecordAndFlushSegment(emulateReactivation)
196+
expect(httpRequestSpy.send).toHaveBeenCalled()
197+
})
198+
199+
it('next segment is created because of re-activation', async () => {
200+
addRecordAndFlushSegment(emulateReactivation)
201+
addRecordAndFlushSegment()
202+
expect((await readMostRecentMetadata(httpRequestSpy.sendOnExit)).creation_reason).toBe('view_change')
203+
})
204+
})
205+
189206
describe('flush when reaching a bytes limit', () => {
190207
it('uses `httpRequest.send` when sending the segment', () => {
191208
addRecordAndFlushSegment(() => {

packages/rum/src/domain/segmentCollection/segmentCollection.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ export function doStartSegmentCollection(
9696
}
9797
)
9898

99+
// When the page is re-activated (window/tab switched back to), flush the current segment so the
100+
// next one starts fresh with the full snapshot taken by startFullSnapshots on the same event.
101+
// Reuses the 'view_change' creation reason to avoid a schema change.
102+
const { unsubscribe: unsubscribeReactivated } = lifeCycle.subscribe(LifeCycleEventType.PAGE_REACTIVATED, () => {
103+
flushSegment('view_change')
104+
})
105+
99106
function flushSegment(flushReason: FlushReason) {
100107
if (state.status === SegmentCollectionStatus.SegmentPending) {
101108
state.segment.flush((metadata, encoderResult) => {
@@ -154,6 +161,7 @@ export function doStartSegmentCollection(
154161
flushSegment('stop')
155162
unsubscribeViewCreated()
156163
unsubscribePageMayExit()
164+
unsubscribeReactivated()
157165
},
158166
}
159167
}

0 commit comments

Comments
 (0)