From 344320bd78beaaaacea2327db1b549d3f02a56ae Mon Sep 17 00:00:00 2001 From: Ihor Romaniuk Date: Thu, 27 Aug 2026 12:10:32 +0200 Subject: [PATCH] fix: stop the course outline scrolling back while you work in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a course outline through a link that points at one of its blocks left the page snapping back to that block on almost every interaction — opening a section menu was enough. The block stays highlighted for as long as the link is in the address bar, and the outline reads that as "scroll to it" again each time it re-renders. The global-state hook handed out a new pair of callbacks on every render, and the cards that scroll to the highlighted block list one of those callbacks among the dependencies of the effect that scrolls. The callbacks now keep their identity, so the effect runs when the highlighted block changes rather than on every render. Scrolling to the block on arrival is unchanged. --- src/data/apiHooks.test.tsx | 62 +++++++++++++++++++++++++++++++++++++- src/data/apiHooks.ts | 16 +++++----- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/data/apiHooks.test.tsx b/src/data/apiHooks.test.tsx index 996a62daf1..7dd143f805 100644 --- a/src/data/apiHooks.test.tsx +++ b/src/data/apiHooks.test.tsx @@ -1,3 +1,6 @@ +import { useRef } from 'react'; +import userEvent from '@testing-library/user-event'; + import { initializeMocks, cleanup, @@ -5,7 +8,7 @@ import { render, waitFor, } from '../testUtils'; -import { useWaffleFlags } from './apiHooks'; +import { createGlobalState, useWaffleFlags } from './apiHooks'; import { getApiWaffleFlagsUrl } from './api'; // A little component for testing our waffle flag hooks. @@ -110,3 +113,60 @@ describe('useWaffleFlags', () => { expect(await screen.findByLabelText('useReactMarkdownEditor')).toHaveTextContent('enabled'); }); }); + +// A little component for testing the global state hooks. +const useCounter = createGlobalState<{ count: number; }>(() => ['test', 'counter'], { count: 0 }); + +const CounterComponent = () => { + const { data, setData, resetData } = useCounter(); + const firstSetData = useRef(setData); + const firstResetData = useRef(resetData); + const callbacksKeptIdentity = setData === firstSetData.current && resetData === firstResetData.current; + + return ( + + ); +}; + +describe('createGlobalState', () => { + it('keeps its callbacks across renders, so effects depending on them do not re-run', async () => { + const user = userEvent.setup(); + initializeMocks(); + render(); + await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('0')); + + await user.click(screen.getByRole('button', { name: 'increment' })); + await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('1')); + + expect(screen.getByLabelText('callbacks')).toHaveTextContent('same'); + }); + + it('resets the value it stores', async () => { + const user = userEvent.setup(); + initializeMocks(); + render(); + await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('0')); + + await user.click(screen.getByRole('button', { name: 'increment' })); + await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('1')); + + await user.click(screen.getByRole('button', { name: 'reset' })); + await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('0')); + }); +}); diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts index ba8baee8ff..f2652f3994 100644 --- a/src/data/apiHooks.ts +++ b/src/data/apiHooks.ts @@ -1,5 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies */ import { AxiosError } from 'axios'; +import { useCallback, useMemo } from 'react'; + import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { UserAgreement, UserAgreementRecord } from '@src/data/types'; @@ -158,7 +160,7 @@ export function createGlobalState( ) { return (queryKeyArgs?: any) => { const queryClient = useQueryClient(); - const queryKey = queryKeyFn(queryKeyArgs); + const queryKey = useMemo(() => queryKeyFn(queryKeyArgs), [queryKeyArgs]); const { data } = useQuery({ queryKey, @@ -170,15 +172,13 @@ export function createGlobalState( refetchIntervalInBackground: false, }); - function setData(x: Partial) { + const setData = useCallback((x: Partial) => { queryClient.setQueryData(queryKey, x); - } + }, [queryClient, queryKey]); - async function resetData() { - await queryClient.invalidateQueries({ - queryKey, - }); - } + const resetData = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey }); + }, [queryClient, queryKey]); return { data, setData, resetData }; };