From ec06a99fdb3d7f21f292da9bc2882832f96d7692 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Singh Date: Fri, 24 Apr 2026 13:45:17 +0530 Subject: [PATCH] fix(client): debounce challenge submissions (#67039) --- .../templates/Challenges/classic/editor.tsx | 7 +- .../utils/fetch-all-curriculum-data.test.tsx | 72 +++++++++++++++++++ .../utils/fetch-all-curriculum-data.tsx | 31 +++++++- 3 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 client/src/templates/Challenges/utils/fetch-all-curriculum-data.test.tsx diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index 2cd24501474..b4e5cc1fc19 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -16,7 +16,6 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import store from 'store'; -import { debounce } from 'lodash-es'; import { useTranslation } from 'react-i18next'; import { Loader } from '../../../components/helpers'; import { LocalStorageThemes } from '../../../redux/types'; @@ -316,10 +315,6 @@ const Editor = (props: EditorProps): JSX.Element => { const submitChallenge = useSubmit(); - const submitChallengeDebounceRef = useRef( - debounce(submitChallenge, 1000, { leading: true, trailing: false }) - ); - const player = useRef<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any sampler: any; @@ -820,7 +815,7 @@ const Editor = (props: EditorProps): JSX.Element => { props.executeChallenge(); } - const tryToSubmitChallenge = submitChallengeDebounceRef.current; + const tryToSubmitChallenge = submitChallenge; // TODO: there's a potential performance gain to be had by only updating when // the outputViewZone has actually changed. diff --git a/client/src/templates/Challenges/utils/fetch-all-curriculum-data.test.tsx b/client/src/templates/Challenges/utils/fetch-all-curriculum-data.test.tsx new file mode 100644 index 00000000000..c775e1b6b51 --- /dev/null +++ b/client/src/templates/Challenges/utils/fetch-all-curriculum-data.test.tsx @@ -0,0 +1,72 @@ +import { renderHook, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSubmit } from './fetch-all-curriculum-data'; + +const { mockDispatch } = vi.hoisted(() => ({ + mockDispatch: vi.fn() +})); + +vi.mock('react-redux', () => ({ + useDispatch: () => mockDispatch +})); + +vi.mock('gatsby', () => ({ + graphql: vi.fn(), + useStaticQuery: () => ({ + allChallengeNode: { nodes: [] }, + allCertificateNode: { nodes: [] }, + allSuperBlockStructure: { nodes: [] } + }) +})); + +describe('useSubmit', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockDispatch.mockReset(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('should debounce rapid submissions', () => { + const { result } = renderHook(() => useSubmit()); + + act(() => { + result.current(); + result.current(); + result.current(); + }); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(1001); + result.current(); + }); + + expect(mockDispatch).toHaveBeenCalledTimes(2); + }); + + it('should debounce per hook instance', () => { + const { result: first } = renderHook(() => useSubmit()); + const { result: second } = renderHook(() => useSubmit()); + + act(() => { + first.current(); + second.current(); + }); + + expect(mockDispatch).toHaveBeenCalledTimes(2); + + act(() => { + vi.advanceTimersByTime(1001); + first.current(); + second.current(); + }); + + expect(mockDispatch).toHaveBeenCalledTimes(4); + }); +}); diff --git a/client/src/templates/Challenges/utils/fetch-all-curriculum-data.tsx b/client/src/templates/Challenges/utils/fetch-all-curriculum-data.tsx index 9022f8cd9c5..ed836e68661 100644 --- a/client/src/templates/Challenges/utils/fetch-all-curriculum-data.tsx +++ b/client/src/templates/Challenges/utils/fetch-all-curriculum-data.tsx @@ -1,5 +1,6 @@ import { useDispatch } from 'react-redux'; import { useStaticQuery, graphql } from 'gatsby'; +import { useEffect, useRef } from 'react'; import { submitChallenge } from '../redux/actions'; import { curriculumData } from '../../../services/curriculum-data'; @@ -8,7 +9,8 @@ import type { ChallengeNode, SuperBlockStructure } from '../../../redux/prop-types'; -import { useEffect } from 'react'; + +const SUBMIT_DEBOUNCE_MS = 1000; interface AllCurriculumData { allChallengeNode: { nodes: ChallengeNode[] }; @@ -87,6 +89,31 @@ export function useSubmit() { // Ensure curriculum data is loaded before challenge submission useFetchAllCurriculumData(); const dispatch = useDispatch(); + const isSubmitLockedRef = useRef(false); + const submitLockTimeoutRef = useRef | null>( + null + ); - return () => dispatch(submitChallenge()); + useEffect( + () => () => { + if (submitLockTimeoutRef.current !== null) { + clearTimeout(submitLockTimeoutRef.current); + } + }, + [] + ); + + return () => { + if (isSubmitLockedRef.current) { + return; + } + + isSubmitLockedRef.current = true; + submitLockTimeoutRef.current = setTimeout(() => { + isSubmitLockedRef.current = false; + submitLockTimeoutRef.current = null; + }, SUBMIT_DEBOUNCE_MS); + + return dispatch(submitChallenge()); + }; }