Skip to content

Commit 6d3cb32

Browse files
authored
fix(replay): handle fast forwarding for arbitrary position jumps in replayer (#248)
fixes REPLAY-667 fixes REPLAY-558 Previously, fast forwarding through a replay was buggy and inconsistent if you scrub or jump positions. This is because when users scrub or jump to arbitrary timestamps, the cached nextUserInteractionEvent and state become stale and prevent proper re-evaluation of fast forwarding. We now extend the Replayer class by adding a private function reevaluateFastForward that will handle arbitrary position jumps. reevaluateFastForward finds the current event index using binary search, scans forward to find the next user interaction position, and calculates the gap of inactivity between them. If the gap is large enough, fast forwarding is triggered. We call this function if we jump/scrub to a new position, if the skipInactive config changes, and on starting playback. Before: - Large period of inactivity at start never fast fowards - Clicking to a breadcrumb triggers fastforwarding, but scrubbing or jumping to that same area doesn't - Scrubbing back while fastforwarding within the same period of inactivity loses the fastforwarding but the badge stays up https://github.com/user-attachments/assets/eac9e993-0946-4666-a40d-239679013c46 After: - Correctly fast forward at the start - Scrubbing or jumping to any position triggers the correct fastforwarding behavior, and the badge is consistent https://github.com/user-attachments/assets/7700da8a-ed3a-4ca0-9ccb-4fd54fb5be45
2 parents 454ef4f + d0fa140 commit 6d3cb32

File tree

3 files changed

+738
-0
lines changed

3 files changed

+738
-0
lines changed

packages/rrweb/src/replay/index.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,29 @@ const mitt = mittProxy.default || mittProxy;
102102

103103
const REPLAY_CONSOLE_PREFIX = '[replayer]';
104104

105+
export function getEventIndex(
106+
events: eventWithTime[],
107+
eventTime: number,
108+
): number {
109+
// Use binary search (O(log n)) to find the index of the event at or before the given time
110+
let result = -1;
111+
if (events.length === 0) {
112+
return result;
113+
}
114+
let left = 0,
115+
right = events.length - 1;
116+
while (left <= right) {
117+
const mid = Math.floor((left + right) / 2);
118+
if (events[mid].timestamp <= eventTime) {
119+
result = mid;
120+
left = mid + 1;
121+
} else {
122+
right = mid - 1;
123+
}
124+
}
125+
return result;
126+
}
127+
105128
const defaultMouseTailConfig = {
106129
duration: 500,
107130
lineCap: 'round',
@@ -482,6 +505,7 @@ export class Replayer {
482505
}
483506

484507
public setConfig(config: Partial<playerConfig>) {
508+
const previousSkipInactive = this.config.skipInactive;
485509
Object.keys(config).forEach((key) => {
486510
const newConfigValue = config[key as keyof playerConfig];
487511
(this.config as Record<keyof playerConfig, typeof newConfigValue>)[
@@ -490,6 +514,11 @@ export class Replayer {
490514
});
491515
if (!this.config.skipInactive) {
492516
this.backToNormal();
517+
} else if (
518+
previousSkipInactive === false &&
519+
this.config.skipInactive === true
520+
) {
521+
this.reevaluateFastForward();
493522
}
494523
if (typeof config.speed !== 'undefined') {
495524
this.speedService.send({
@@ -557,6 +586,12 @@ export class Replayer {
557586
* @param timeOffset - number
558587
*/
559588
public play(timeOffset = 0) {
589+
if (
590+
this.config.skipInactive &&
591+
this.speedService.state.matches('skipping')
592+
) {
593+
this.backToNormal();
594+
}
560595
if (this.service.state.matches('paused')) {
561596
this.service.send({ type: 'PLAY', payload: { timeOffset } });
562597
} else {
@@ -568,6 +603,9 @@ export class Replayer {
568603
?.getElementsByTagName('html')[0]
569604
?.classList.remove('rrweb-paused');
570605
this.emitter.emit(ReplayerEvents.Start);
606+
if (this.config.skipInactive) {
607+
this.reevaluateFastForward();
608+
}
571609
}
572610

573611
public pause(timeOffset?: number) {
@@ -633,6 +671,54 @@ export class Replayer {
633671
this.cache = createCache();
634672
}
635673

674+
private reevaluateFastForward(): void {
675+
if (!this.config.skipInactive) {
676+
return;
677+
}
678+
679+
// Clear stale state
680+
this.nextUserInteractionEvent = null;
681+
682+
// Get current time and convert to event-relative time
683+
const events = this.service.state.context.events;
684+
const firstEvent = events[0];
685+
if (!firstEvent) {
686+
return;
687+
}
688+
const currentEventTime = firstEvent.timestamp + this.getCurrentTime();
689+
690+
// Find current event index
691+
const currentEventIndex = getEventIndex(events, currentEventTime);
692+
if (currentEventIndex === -1) {
693+
return;
694+
}
695+
696+
// Find next user interaction event starting from the current event index
697+
const currentEvent = events[currentEventIndex];
698+
const threshold =
699+
this.config.inactivePeriodThreshold *
700+
this.speedService.state.context.timer.speed;
701+
for (let i = currentEventIndex + 1; i < events.length; i++) {
702+
const event = events[i];
703+
if (this.isUserInteraction(event)) {
704+
const gapTime = event.timestamp - currentEvent.timestamp;
705+
// Fast forward if the gap time is greater than the threshold
706+
if (gapTime > threshold) {
707+
this.nextUserInteractionEvent = event;
708+
const payload = {
709+
speed: Math.min(
710+
Math.round(gapTime / SKIP_TIME_INTERVAL),
711+
this.config.maxSpeed,
712+
),
713+
};
714+
this.speedService.send({ type: 'FAST_FORWARD', payload });
715+
this.emitter.emit(ReplayerEvents.SkipStart, payload);
716+
}
717+
break;
718+
}
719+
}
720+
}
721+
636722
private setupDom() {
637723
this.wrapper = document.createElement('div');
638724
this.wrapper.classList.add('replayer-wrapper');

0 commit comments

Comments
 (0)