fix: ensure iframe visibility tracking is triggered on load

The previous implementation had a race condition that sometimes prevented
XBlocks from being marked as viewed. Users had to scroll or resize the window
to trigger visibility tracking instead of having it happen once content loads.
This commit is contained in:
Agrendalath
2025-04-05 18:09:46 +02:00
committed by Farhaan Bukhsh
parent af50d5a6ed
commit 870263001e
2 changed files with 38 additions and 28 deletions

View File

@@ -280,7 +280,7 @@ describe('useIFrameBehavior hook', () => {
});
});
describe('visibility tracking', () => {
it('sets up visibility tracking after iframe has loaded', () => {
it('sets up visibility tracking after iframe loads', () => {
mockState({ ...defaultStateVals, hasLoaded: true });
renderHook(() => useIFrameBehavior(props));
@@ -288,15 +288,9 @@ describe('useIFrameBehavior hook', () => {
expect(global.window.addEventListener).toHaveBeenCalledTimes(2);
expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
// Initial visibility update.
expect(postMessage).toHaveBeenCalledWith(
{
type: 'unit.visibilityStatus',
data: {
topPosition: 100,
viewportHeight: 800,
},
},
// Initial visibility update is handled by the `handleIFrameLoad` method.
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'unit.visibilityStatus' }),
config.LMS_BASE_URL,
);
});
@@ -362,6 +356,20 @@ describe('useIFrameBehavior hook', () => {
window.onmessage(event);
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
});
it('updates initial iframe visibility on load', () => {
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.handleIFrameLoad();
expect(postMessage).toHaveBeenCalledWith(
{
type: 'unit.visibilityStatus',
data: {
topPosition: 100,
viewportHeight: 800,
},
},
config.LMS_BASE_URL,
);
});
});
it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => {
mockState(stateVals);

View File

@@ -102,6 +102,23 @@ const useIFrameBehavior = ({
useEventListener('message', receiveMessage);
// Send visibility status to the iframe. It's used to mark XBlocks as viewed.
const updateIframeVisibility = () => {
const iframeElement = document.getElementById(elementId) as HTMLIFrameElement | null;
const rect = iframeElement?.getBoundingClientRect();
const visibleInfo = {
type: 'unit.visibilityStatus',
data: {
topPosition: rect?.top,
viewportHeight: window.innerHeight,
},
};
iframeElement?.contentWindow?.postMessage(
visibleInfo,
`${getConfig().LMS_BASE_URL}`,
);
};
// Set up visibility tracking event listeners.
React.useEffect(() => {
if (!hasLoaded) {
return undefined;
@@ -112,27 +129,9 @@ const useIFrameBehavior = ({
return undefined;
}
const updateIframeVisibility = () => {
const rect = iframeElement.getBoundingClientRect();
const visibleInfo = {
type: 'unit.visibilityStatus',
data: {
topPosition: rect.top,
viewportHeight: window.innerHeight,
},
};
iframeElement?.contentWindow?.postMessage(
visibleInfo,
`${getConfig().LMS_BASE_URL}`,
);
};
// Throttle the update function to prevent it from sending too many messages to the iframe.
const throttledUpdateVisibility = throttle(updateIframeVisibility, 100);
// Update the visibility of the iframe in case the element is already visible.
updateIframeVisibility();
// Add event listeners to update the visibility of the iframe when the window is scrolled or resized.
window.addEventListener('scroll', throttledUpdateVisibility);
window.addEventListener('resize', throttledUpdateVisibility);
@@ -167,6 +166,9 @@ const useIFrameBehavior = ({
dispatch(processEvent(e.data, fetchCourse));
}
};
// Update the visibility of the iframe in case the element is already visible.
updateIframeVisibility();
};
React.useEffect(() => {