Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reader: fix scroll depth tracking #98093

Merged
merged 6 commits into from
Jan 10, 2025
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
50 changes: 18 additions & 32 deletions client/blocks/reader-full-post/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import isSiteWPForTeams from 'calypso/state/selectors/is-site-wpforteams';
import { disableAppBanner, enableAppBanner } from 'calypso/state/ui/actions';
import ReaderFullPostHeader from './header';
import ReaderFullPostContentPlaceholder from './placeholders/content';
import ScrollTracker from './scroll-tracker';
import ReaderFullPostUnavailable from './unavailable';

import './style.scss';
Expand All @@ -101,8 +102,6 @@ export class FullPostView extends Component {

state = {
isSuggestedFollowsModalOpen: false,
maxScrollDepth: 0, // Track the maximum scroll depth achieved
hasCompleted: false, // Track whether the user completed the post
Comment on lines -104 to -105
Copy link
Contributor Author

@holdercp holdercp Jan 8, 2025

Choose a reason for hiding this comment

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

No longer need this component state because we just need to know the max scroll depth at the moment when we unmount or the post/feed changes. All of that tracking logic was moved to scroll-tracker.ts and just uses event listeners.

};

openSuggestedFollowsModal = ( followClicked ) => {
Expand All @@ -113,6 +112,7 @@ export class FullPostView extends Component {
};

componentDidMount() {
this.scrollTracker = new ScrollTracker();
// Send page view
this.hasSentPageView = false;
this.hasLoaded = false;
Expand Down Expand Up @@ -141,8 +141,8 @@ export class FullPostView extends Component {
document.querySelector( '#primary > div > div.recent-feed > section' ) || // for Recent Feed in Dataview
document.querySelector( '#primary > div > div' ); // for Recent Feed in Stream
if ( scrollableContainer ) {
scrollableContainer.addEventListener( 'scroll', this.setScrollDepth );
this.scrollableContainer = scrollableContainer; // Save reference for cleanup
this.scrollableContainer = scrollableContainer;
this.scrollTracker.setContainer( scrollableContainer );
this.resetScroll();
}
}
Expand Down Expand Up @@ -197,9 +197,9 @@ export class FullPostView extends Component {
document.removeEventListener( 'keydown', this.handleKeydown, true );
document.removeEventListener( 'visibilitychange', this.handleVisibilityChange );

if ( this.scrollableContainer ) {
this.scrollableContainer.removeEventListener( 'scroll', this.setScrollDepth );
}
// Track scroll depth and remove related instruments
this.trackScrollDepth( this.props.post );
Copy link
Contributor Author

@holdercp holdercp Jan 8, 2025

Choose a reason for hiding this comment

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

We need to track the scroll depth when the component is about to unmount. We were missing events when the feed would change or a new page in the middle column was requested because the component actually unmounts/remounts in those cases.

this.scrollTracker.cleanup();
this.clearResetScrollTimeout();
}

Expand Down Expand Up @@ -280,51 +280,37 @@ export class FullPostView extends Component {
resetScroll = () => {
this.clearResetScrollTimeout();
this.resetScrollTimeout = setTimeout( () => {
if ( this.scrollableContainer ) {
this.scrollableContainer.scrollTo( {
top: 0,
left: 0,
behavior: 'instant',
} );
}
this.setState( { maxScrollDepth: 0, hasCompleted: false } );
this.scrollableContainer.scrollTo( {
top: 0,
left: 0,
behavior: 'instant',
} );
this.scrollTracker.resetMaxScrollDepth();
}, 0 ); // Defer until after the DOM update
};

setScrollDepth = () => {
if ( this.scrollableContainer ) {
const scrollTop = this.scrollableContainer.scrollTop;
const scrollHeight = this.scrollableContainer.scrollHeight;
const clientHeight = this.scrollableContainer.clientHeight;
const scrollDepth = ( scrollTop / ( scrollHeight - clientHeight ) ) * 100;
this.setState( ( prevState ) => ( {
maxScrollDepth: Math.max( prevState.maxScrollDepth, scrollDepth ) || 0,
hasCompleted: prevState.hasCompleted || scrollDepth >= 90,
} ) );
}
};

trackScrollDepth = ( post = null ) => {
const { maxScrollDepth } = this.state;
if ( ! post ) {
post = this.props.post;
}

if ( this.scrollableContainer && post.ID ) {
const roundedDepth = Math.round( maxScrollDepth * 100 ) / 100;
const maxScrollDepth = this.scrollTracker.getMaxScrollDepthAsPercentage();
recordTrackForPost( 'calypso_reader_article_scroll_depth', post, {
context: 'full-post',
scroll_depth: roundedDepth,
scroll_depth: maxScrollDepth,
} );
}
};

trackExitBeforeCompletion = ( post = null ) => {
const { hasCompleted, maxScrollDepth } = this.state;
if ( ! post ) {
post = this.props.post;
}

const maxScrollDepth = this.scrollTracker.getMaxScrollDepthAsPercentage();
const hasCompleted = maxScrollDepth >= 90; // User has read 90% of the post

if ( this.scrollableContainer && post.ID && ! hasCompleted ) {
recordTrackForPost( 'calypso_reader_article_exit_before_completion', post, {
context: 'full-post',
Expand Down
79 changes: 79 additions & 0 deletions client/blocks/reader-full-post/scroll-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Tracks scroll depth for a container element.
*/
export default class ScrollTracker {
private container: HTMLElement | null = null;
private maxScrollDepth: number = 0;

private handleScroll = (): void => {
if ( ! this.container ) {
return;
}

const scrollTop = this.container.scrollTop ?? 0;
const scrollHeight = this.container.scrollHeight ?? 0;
const clientHeight = this.container.clientHeight ?? 0;

const denominator = scrollHeight - clientHeight;
const scrollDepth = denominator <= 0 ? 0 : scrollTop / denominator;

this.maxScrollDepth = calcAndClampMaxValue( scrollDepth, this.maxScrollDepth );
};

/**
* Sets the container element to track scrolling on.
* Removes scroll listener from previous container if it exists.
* @param container - The HTML element to track scrolling on, or null to stop tracking
*/
public setContainer( container: HTMLElement | null ): void {
this.cleanup();
this.resetMaxScrollDepth();
this.container = container;
if ( container ) {
container.addEventListener( 'scroll', this.handleScroll );
}
}

/**
* Gets the maximum scroll depth reached as a decimal between 0 and 1.
* @returns A number between 0 and 1 representing the maximum scroll depth
*/
public getMaxScrollDepth(): number {
return this.maxScrollDepth;
}

/**
* Gets the maximum scroll depth reached as a percentage between 0 and 100.
* @returns A rounded number between 0 and 100 representing the maximum scroll depth percentage
*/
public getMaxScrollDepthAsPercentage(): number {
return Math.round( this.maxScrollDepth * 100 );
}

/**
* Resets the maximum scroll depth back to 0.
*/
public resetMaxScrollDepth = (): void => {
this.maxScrollDepth = 0;
};

/**
* Removes scroll event listener from container.
* Should be called when tracking is no longer needed.
*/
public cleanup(): void {
if ( this.container ) {
this.container.removeEventListener( 'scroll', this.handleScroll );
}
}
}

/**
* Calculates the maximum value between two numbers and clamps the result between 0 and 1.
* @param valueA - First number to compare
* @param valueB - Second number to compare
* @returns A number between 0 and 1 representing the maximum value between valueA and valueB
*/
function calcAndClampMaxValue( valueA: number, valueB: number ): number {
return Math.min( 1, Math.max( 0, valueA, valueB ) );
}
130 changes: 130 additions & 0 deletions client/blocks/reader-full-post/test/scroll-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* @jest-environment jsdom
*/
import ScrollTracker from '../scroll-tracker';

describe( 'ScrollTracker', () => {
let scrollTracker;
let container;

beforeEach( () => {
scrollTracker = new ScrollTracker();
container = document.createElement( 'div' );
// Setup scrollable container
Object.defineProperties( container, {
scrollTop: { value: 0, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
} );
} );

afterEach( () => {
scrollTracker.cleanup();
} );

describe( 'getMaxScrollDepth()', () => {
it( 'should return 0 when no scrolling has occurred', () => {
expect( scrollTracker.getMaxScrollDepth() ).toBe( 0 );
} );

it( 'should return correct depth when scrolled', () => {
scrollTracker.setContainer( container );
Object.defineProperty( container, 'scrollTop', { value: 250 } );
container.dispatchEvent( new Event( 'scroll' ) );
expect( scrollTracker.getMaxScrollDepth() ).toBe( 0.5 );
} );

it( 'should return the highest depth reached', () => {
scrollTracker.setContainer( container );

Object.defineProperty( container, 'scrollTop', { value: 375 } );
container.dispatchEvent( new Event( 'scroll' ) );

Object.defineProperty( container, 'scrollTop', { value: 250 } );
container.dispatchEvent( new Event( 'scroll' ) );

expect( scrollTracker.getMaxScrollDepth() ).toBe( 0.75 );
} );

it( 'should reset when container changes', () => {
scrollTracker.setContainer( container );
Object.defineProperty( container, 'scrollTop', { value: 250 } );
container.dispatchEvent( new Event( 'scroll' ) );

const newContainer = document.createElement( 'div' );
Object.defineProperties( newContainer, {
scrollTop: { value: 0, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
} );
scrollTracker.setContainer( newContainer );

expect( scrollTracker.getMaxScrollDepth() ).toBe( 0 );
} );
} );

describe( 'getMaxScrollDepthAsPercentage()', () => {
it( 'should return 0 when no scrolling has occurred', () => {
expect( scrollTracker.getMaxScrollDepthAsPercentage() ).toBe( 0 );
} );

it( 'should return the correct percentage when scrolled', () => {
scrollTracker.setContainer( container );
Object.defineProperty( container, 'scrollTop', { value: 250 } );
container.dispatchEvent( new Event( 'scroll' ) );
expect( scrollTracker.getMaxScrollDepthAsPercentage() ).toBe( 50 );
} );

it( 'should return the highest percentage reached', () => {
scrollTracker.setContainer( container );

Object.defineProperty( container, 'scrollTop', { value: 375 } );
container.dispatchEvent( new Event( 'scroll' ) );

Object.defineProperty( container, 'scrollTop', { value: 250 } );
container.dispatchEvent( new Event( 'scroll' ) );

expect( scrollTracker.getMaxScrollDepthAsPercentage() ).toBe( 75 );
} );
} );

describe( 'setContainer()', () => {
it( 'should track scroll events on the new container', () => {
scrollTracker.setContainer( container );
Object.defineProperty( container, 'scrollTop', { value: 250 } );
container.dispatchEvent( new Event( 'scroll' ) );
expect( scrollTracker.getMaxScrollDepthAsPercentage() ).toBe( 50 );
} );

it( 'should stop tracking previous container when setting new one', () => {
const oldContainer = document.createElement( 'div' );
Object.defineProperties( oldContainer, {
scrollTop: { value: 0, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
} );

scrollTracker.setContainer( oldContainer );
Object.defineProperty( oldContainer, 'scrollTop', { value: 250 } );
oldContainer.dispatchEvent( new Event( 'scroll' ) );

scrollTracker.setContainer( container );
Object.defineProperty( oldContainer, 'scrollTop', { value: 375 } );
oldContainer.dispatchEvent( new Event( 'scroll' ) );

expect( scrollTracker.getMaxScrollDepthAsPercentage() ).toBe( 0 );
} );
} );

describe( 'cleanup()', () => {
it( 'should stop tracking scroll events', () => {
scrollTracker.setContainer( container );
scrollTracker.cleanup();

Object.defineProperty( container, 'scrollTop', { value: 250 } );
container.dispatchEvent( new Event( 'scroll' ) );

expect( scrollTracker.getMaxScrollDepthAsPercentage() ).toBe( 0 );
} );
} );
} );
2 changes: 2 additions & 0 deletions client/reader/recent/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ body.is-reader-full-post {
);

height: $feed-content-height !important; // Using !important here to avoid having a lengthy selector.
overflow-y: hidden !important; // Keeps the scrolling container consistent between one and two column layouts.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These css changes fix an issue where the scroll container changed based on the viewport width, meaning we were no longer getting events. This ensures that the same DOM element is the scrolling container in the Recent feed.

Copy link
Contributor

Choose a reason for hiding this comment

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

meaning we were no longer getting events

I understand the motivation, but looks like this had a side-effect. In the reader recent tab, if you scroll down while reading a longish post and click on another post, the scroll position doesn't reset to the top of the post.

We fixed it here should you want to see the before and after videos, but I suspect this change makes it come back since it doesn't happen on trunk.


@media ( min-width: $break-wide ) {
display: flex;
Expand Down Expand Up @@ -200,6 +201,7 @@ body.is-reader-full-post {
@extend %column-shared;
display: none;
overflow-y: auto;
height: 100%;

&.overlay {
display: block;
Expand Down
Loading