From 0a1c4441b636e59fe1fb84541345335799cb8f06 Mon Sep 17 00:00:00 2001 From: Srikanth Patchava Date: Fri, 24 Apr 2026 22:04:02 -0700 Subject: [PATCH 1/4] fix: handle null-prototype objects in typeName helper typeName() accessed value.constructor.name without guarding against null-prototype objects (created via Object.create(null)), which have no constructor property. Added a guard with fallback to Object.prototype.toString.call() and a try-catch wrapper. Signed-off-by: Srikanth Patchava Signed-off-by: Srikanth Patchava --- .../apps/playground/scripts/link-compiler.sh | 0 .../scripts/link-react-compiler-runtime.sh | 0 .../scripts/ts-analyze-trace.sh | 0 .../packages/react-forgive/scripts/build.mjs | 0 .../scripts/link-react-compiler-runtime.sh | 0 compiler/scripts/enable-feature-flag.js | 0 compiler/scripts/hash.sh | 0 compiler/scripts/release/publish.js | 0 fixtures/devtools/regression/server.js | 0 fixtures/devtools/scheduling-profiler/run.js | 0 packages/react-devtools/bin.js | 0 packages/shared/CheckStringCoercion.js | 22 ++++++++++++------- scripts/ci/check_license.sh | 0 .../ci/download_devtools_regression_build.js | 0 .../ci/pack_and_store_devtools_artifacts.sh | 0 scripts/ci/run_devtools_e2e_tests.js | 0 scripts/ci/test_print_warnings.sh | 0 scripts/devtools/build-and-test.js | 0 scripts/devtools/prepare-release.js | 0 scripts/devtools/publish-release.js | 0 scripts/git/pre-commit | 0 scripts/react-compiler/build-compiler.sh | 0 scripts/react-compiler/link-compiler.sh | 0 scripts/release/build-release-locally.js | 0 .../release/download-experimental-build.js | 0 scripts/release/prepare-release-from-ci.js | 0 scripts/release/prepare-release-from-npm.js | 0 scripts/release/publish.js | 0 scripts/release/snapshot-test.js | 0 scripts/worktree.sh | 0 30 files changed, 14 insertions(+), 8 deletions(-) mode change 100755 => 100644 compiler/apps/playground/scripts/link-compiler.sh mode change 100755 => 100644 compiler/packages/babel-plugin-react-compiler/scripts/link-react-compiler-runtime.sh mode change 100755 => 100644 compiler/packages/babel-plugin-react-compiler/scripts/ts-analyze-trace.sh mode change 100755 => 100644 compiler/packages/react-forgive/scripts/build.mjs mode change 100755 => 100644 compiler/packages/snap/scripts/link-react-compiler-runtime.sh mode change 100755 => 100644 compiler/scripts/enable-feature-flag.js mode change 100755 => 100644 compiler/scripts/hash.sh mode change 100755 => 100644 compiler/scripts/release/publish.js mode change 100755 => 100644 fixtures/devtools/regression/server.js mode change 100755 => 100644 fixtures/devtools/scheduling-profiler/run.js mode change 100755 => 100644 packages/react-devtools/bin.js mode change 100755 => 100644 scripts/ci/check_license.sh mode change 100755 => 100644 scripts/ci/download_devtools_regression_build.js mode change 100755 => 100644 scripts/ci/pack_and_store_devtools_artifacts.sh mode change 100755 => 100644 scripts/ci/run_devtools_e2e_tests.js mode change 100755 => 100644 scripts/ci/test_print_warnings.sh mode change 100755 => 100644 scripts/devtools/build-and-test.js mode change 100755 => 100644 scripts/devtools/prepare-release.js mode change 100755 => 100644 scripts/devtools/publish-release.js mode change 100755 => 100644 scripts/git/pre-commit mode change 100755 => 100644 scripts/react-compiler/build-compiler.sh mode change 100755 => 100644 scripts/react-compiler/link-compiler.sh mode change 100755 => 100644 scripts/release/build-release-locally.js mode change 100755 => 100644 scripts/release/download-experimental-build.js mode change 100755 => 100644 scripts/release/prepare-release-from-ci.js mode change 100755 => 100644 scripts/release/prepare-release-from-npm.js mode change 100755 => 100644 scripts/release/publish.js mode change 100755 => 100644 scripts/release/snapshot-test.js mode change 100755 => 100644 scripts/worktree.sh diff --git a/compiler/apps/playground/scripts/link-compiler.sh b/compiler/apps/playground/scripts/link-compiler.sh old mode 100755 new mode 100644 diff --git a/compiler/packages/babel-plugin-react-compiler/scripts/link-react-compiler-runtime.sh b/compiler/packages/babel-plugin-react-compiler/scripts/link-react-compiler-runtime.sh old mode 100755 new mode 100644 diff --git a/compiler/packages/babel-plugin-react-compiler/scripts/ts-analyze-trace.sh b/compiler/packages/babel-plugin-react-compiler/scripts/ts-analyze-trace.sh old mode 100755 new mode 100644 diff --git a/compiler/packages/react-forgive/scripts/build.mjs b/compiler/packages/react-forgive/scripts/build.mjs old mode 100755 new mode 100644 diff --git a/compiler/packages/snap/scripts/link-react-compiler-runtime.sh b/compiler/packages/snap/scripts/link-react-compiler-runtime.sh old mode 100755 new mode 100644 diff --git a/compiler/scripts/enable-feature-flag.js b/compiler/scripts/enable-feature-flag.js old mode 100755 new mode 100644 diff --git a/compiler/scripts/hash.sh b/compiler/scripts/hash.sh old mode 100755 new mode 100644 diff --git a/compiler/scripts/release/publish.js b/compiler/scripts/release/publish.js old mode 100755 new mode 100644 diff --git a/fixtures/devtools/regression/server.js b/fixtures/devtools/regression/server.js old mode 100755 new mode 100644 diff --git a/fixtures/devtools/scheduling-profiler/run.js b/fixtures/devtools/scheduling-profiler/run.js old mode 100755 new mode 100644 diff --git a/packages/react-devtools/bin.js b/packages/react-devtools/bin.js old mode 100755 new mode 100644 diff --git a/packages/shared/CheckStringCoercion.js b/packages/shared/CheckStringCoercion.js index a186d6755d99..7396cf510882 100644 --- a/packages/shared/CheckStringCoercion.js +++ b/packages/shared/CheckStringCoercion.js @@ -20,14 +20,20 @@ // $FlowFixMe[incompatible-return] only called in DEV, so void return is not possible. function typeName(value: mixed): string { if (__DEV__) { - // toStringTag is needed for namespaced types like Temporal.Instant - const hasToStringTag = typeof Symbol === 'function' && Symbol.toStringTag; - const type = - (hasToStringTag && (value: any)[Symbol.toStringTag]) || - (value: any).constructor.name || - 'Object'; - // $FlowFixMe[incompatible-return] - return type; + try { + // toStringTag is needed for namespaced types like Temporal.Instant + const hasToStringTag = typeof Symbol === 'function' && Symbol.toStringTag; + const type = + (hasToStringTag && (value: any)[Symbol.toStringTag]) || + ((value: any).constructor && (value: any).constructor.name) || + Object.prototype.toString.call(value) || + 'Object'; + // $FlowFixMe[incompatible-return] + return type; + } catch (e) { + // $FlowFixMe[incompatible-return] + return 'Object'; + } } } diff --git a/scripts/ci/check_license.sh b/scripts/ci/check_license.sh old mode 100755 new mode 100644 diff --git a/scripts/ci/download_devtools_regression_build.js b/scripts/ci/download_devtools_regression_build.js old mode 100755 new mode 100644 diff --git a/scripts/ci/pack_and_store_devtools_artifacts.sh b/scripts/ci/pack_and_store_devtools_artifacts.sh old mode 100755 new mode 100644 diff --git a/scripts/ci/run_devtools_e2e_tests.js b/scripts/ci/run_devtools_e2e_tests.js old mode 100755 new mode 100644 diff --git a/scripts/ci/test_print_warnings.sh b/scripts/ci/test_print_warnings.sh old mode 100755 new mode 100644 diff --git a/scripts/devtools/build-and-test.js b/scripts/devtools/build-and-test.js old mode 100755 new mode 100644 diff --git a/scripts/devtools/prepare-release.js b/scripts/devtools/prepare-release.js old mode 100755 new mode 100644 diff --git a/scripts/devtools/publish-release.js b/scripts/devtools/publish-release.js old mode 100755 new mode 100644 diff --git a/scripts/git/pre-commit b/scripts/git/pre-commit old mode 100755 new mode 100644 diff --git a/scripts/react-compiler/build-compiler.sh b/scripts/react-compiler/build-compiler.sh old mode 100755 new mode 100644 diff --git a/scripts/react-compiler/link-compiler.sh b/scripts/react-compiler/link-compiler.sh old mode 100755 new mode 100644 diff --git a/scripts/release/build-release-locally.js b/scripts/release/build-release-locally.js old mode 100755 new mode 100644 diff --git a/scripts/release/download-experimental-build.js b/scripts/release/download-experimental-build.js old mode 100755 new mode 100644 diff --git a/scripts/release/prepare-release-from-ci.js b/scripts/release/prepare-release-from-ci.js old mode 100755 new mode 100644 diff --git a/scripts/release/prepare-release-from-npm.js b/scripts/release/prepare-release-from-npm.js old mode 100755 new mode 100644 diff --git a/scripts/release/publish.js b/scripts/release/publish.js old mode 100755 new mode 100644 diff --git a/scripts/release/snapshot-test.js b/scripts/release/snapshot-test.js old mode 100755 new mode 100644 diff --git a/scripts/worktree.sh b/scripts/worktree.sh old mode 100755 new mode 100644 From 68133b92d6a696be378b3170922365bac52adf04 Mon Sep 17 00:00:00 2001 From: Srikanth Patchava Date: Sat, 25 Apr 2026 01:27:36 -0700 Subject: [PATCH 2/4] feat: add useThrottledCallback hook Add throttled callback hook with: - Configurable delay with leading/trailing edge options - Cancel and flush API for pending invocations - Pending state tracking - Proper cleanup on unmount (timer cancellation) - Uses refs to always invoke latest callback without re-creating throttled fn - TypeScript-compatible JSDoc annotations Signed-off-by: Srikanth Patchava --- .../src/hooks/useThrottledCallback.js | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 packages/react-dom/src/hooks/useThrottledCallback.js diff --git a/packages/react-dom/src/hooks/useThrottledCallback.js b/packages/react-dom/src/hooks/useThrottledCallback.js new file mode 100644 index 000000000000..cc545e112bab --- /dev/null +++ b/packages/react-dom/src/hooks/useThrottledCallback.js @@ -0,0 +1,228 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {useCallback, useEffect, useRef} from 'react'; + +/** + * @typedef {Object} ThrottleOptions + * @property {boolean} [leading=true] - Invoke on the leading edge of the timeout + * @property {boolean} [trailing=true] - Invoke on the trailing edge of the timeout + */ + +/** + * @typedef {Object} ThrottledCallbackResult + * @property {Function} callback - The throttled callback function + * @property {Function} cancel - Cancel any pending trailing invocation + * @property {Function} flush - Immediately invoke any pending trailing invocation + * @property {boolean} isPending - Whether a trailing invocation is scheduled + */ + +/** + * useThrottledCallback - A hook that returns a throttled version of a callback. + * + * The throttled callback will only be invoked at most once per `delay` + * milliseconds. Supports leading and trailing edge invocation, cancel/flush + * API, and tracks pending state. + * + * @param {Function} callback - The function to throttle + * @param {number} delay - Throttle delay in milliseconds + * @param {ThrottleOptions} [options] - Configuration options + * @returns {ThrottledCallbackResult} Throttled callback with control methods + */ +export default function useThrottledCallback( + callback, + delay, + options = {}, +) { + const {leading = true, trailing = true} = options; + + // Use refs to always have the latest values without re-creating the throttled fn + const callbackRef = useRef(callback); + const delayRef = useRef(delay); + const leadingRef = useRef(leading); + const trailingRef = useRef(trailing); + + // Mutable state for the throttle mechanism + const timerIdRef = useRef(null); + const lastInvokeTimeRef = useRef(0); + const lastCallArgsRef = useRef(null); + const lastCallContextRef = useRef(null); + const isPendingRef = useRef(false); + const mountedRef = useRef(true); + + // Keep refs in sync with latest values + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + delayRef.current = delay; + }, [delay]); + + useEffect(() => { + leadingRef.current = leading; + }, [leading]); + + useEffect(() => { + trailingRef.current = trailing; + }, [trailing]); + + // Track mounted state for cleanup safety + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + /** + * Invoke the original callback with the most recent arguments. + * Updates last invoke time and clears pending args. + */ + const invokeCallback = useCallback(() => { + const args = lastCallArgsRef.current; + const context = lastCallContextRef.current; + lastCallArgsRef.current = null; + lastCallContextRef.current = null; + lastInvokeTimeRef.current = Date.now(); + isPendingRef.current = false; + if (args !== null) { + callbackRef.current.apply(context, args); + } + }, []); + + /** + * Schedule the trailing edge invocation. + * Only fires if trailing is enabled and there are pending args. + */ + const scheduleTrailing = useCallback(() => { + timerIdRef.current = setTimeout(() => { + timerIdRef.current = null; + if ( + trailingRef.current && + lastCallArgsRef.current !== null && + mountedRef.current + ) { + invokeCallback(); + // After trailing invocation, if more calls came in during + // the trailing wait, we need another cycle + } else { + isPendingRef.current = false; + } + }, delayRef.current); + }, [invokeCallback]); + + /** + * The throttled function. Handles leading/trailing edge logic. + * + * Leading edge: invoke immediately if enough time has passed since + * the last invocation. + * + * Trailing edge: schedule a deferred invocation that fires after + * `delay` ms if no subsequent call occurs. + */ + const throttledCallback = useCallback( + function throttled(...args) { + const now = Date.now(); + const timeSinceLastInvoke = now - lastInvokeTimeRef.current; + const currentDelay = delayRef.current; + + // Store latest call arguments + lastCallArgsRef.current = args; + // eslint-disable-next-line react-hooks/exhaustive-deps + lastCallContextRef.current = this; + + const isFirstCall = lastInvokeTimeRef.current === 0; + const enoughTimePassed = timeSinceLastInvoke >= currentDelay; + + if ((isFirstCall || enoughTimePassed) && leadingRef.current) { + // Leading edge invocation + // Clear any pending trailing timer since we're invoking now + if (timerIdRef.current !== null) { + clearTimeout(timerIdRef.current); + timerIdRef.current = null; + } + invokeCallback(); + + // If trailing is also enabled, schedule a trailing check + // to handle calls that come in during the delay window + if (trailingRef.current) { + isPendingRef.current = true; + scheduleTrailing(); + } + } else if (timerIdRef.current === null) { + // No timer running - schedule trailing invocation + isPendingRef.current = true; + scheduleTrailing(); + } else { + // Timer already running - just update pending args (done above) + isPendingRef.current = true; + } + }, + [invokeCallback, scheduleTrailing], + ); + + /** + * Cancel any pending trailing invocation. Resets all internal state. + */ + const cancel = useCallback(() => { + if (timerIdRef.current !== null) { + clearTimeout(timerIdRef.current); + timerIdRef.current = null; + } + lastCallArgsRef.current = null; + lastCallContextRef.current = null; + isPendingRef.current = false; + }, []); + + /** + * Immediately invoke any pending trailing invocation. + * If there is no pending invocation, this is a no-op. + */ + const flush = useCallback(() => { + if (timerIdRef.current !== null) { + clearTimeout(timerIdRef.current); + timerIdRef.current = null; + } + if (lastCallArgsRef.current !== null && mountedRef.current) { + invokeCallback(); + } + }, [invokeCallback]); + + /** + * Get whether a trailing invocation is currently pending. + * + * @returns {boolean} + */ + const getIsPending = useCallback(() => { + return isPendingRef.current; + }, []); + + // Cleanup on unmount: cancel any pending timers + useEffect(() => { + return () => { + if (timerIdRef.current !== null) { + clearTimeout(timerIdRef.current); + timerIdRef.current = null; + } + lastCallArgsRef.current = null; + lastCallContextRef.current = null; + isPendingRef.current = false; + }; + }, []); + + return { + callback: throttledCallback, + cancel, + flush, + get isPending() { + return getIsPending(); + }, + }; +} From d129c19c37339b5420c0ab641049252a807f090f Mon Sep 17 00:00:00 2001 From: Srikanth Patchava Date: Sat, 25 Apr 2026 01:28:12 -0700 Subject: [PATCH 3/4] fix: prevent stale closure in useSyncExternalStore teardown Add isSubscribed guard to handleStoreChange in subscribeToStore to prevent the callback from firing after the component has conceptually unmounted but before the passive effect cleanup runs. The cleanup now sets isSubscribed=false before calling unsubscribe, closing the timing window where handleStoreChange could operate on a stale fiber reference. Signed-off-by: Srikanth Patchava --- packages/react-reconciler/src/ReactFiberHooks.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 29c83c7d7263..58fa36c8f90c 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1860,7 +1860,14 @@ function subscribeToStore( inst: StoreInstance, subscribe: (() => void) => () => void, ): any { + // Track whether this subscription has been torn down to prevent + // stale closure issues where handleStoreChange fires after the + // component has unmounted but before the unsubscribe cleanup runs. + let isSubscribed = true; const handleStoreChange = () => { + if (!isSubscribed) { + return; + } // The store changed. Check if the snapshot changed since the last time we // read from the store. if (checkIfSnapshotChanged(inst)) { @@ -1870,7 +1877,13 @@ function subscribeToStore( } }; // Subscribe to the store and return a clean-up function. - return subscribe(handleStoreChange); + const unsubscribe = subscribe(handleStoreChange); + return () => { + isSubscribed = false; + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; } function checkIfSnapshotChanged(inst: StoreInstance): boolean { From 6ed3fef453768a41a56f2ee3c43d1874fe5f6b14 Mon Sep 17 00:00:00 2001 From: Srikanth Patchava Date: Sat, 25 Apr 2026 01:29:17 -0700 Subject: [PATCH 4/4] test: add Jest tests for useThrottledCallback hook Test coverage includes: - Leading edge immediate invocation - Throttling within delay window - Leading-only and trailing-only modes - Cancel API prevents pending trailing invocations - Flush API triggers immediate invocation - Latest callback ref is used without re-creating throttled fn - Timer cleanup on unmount prevents stale invocations Signed-off-by: Srikanth Patchava --- .../__tests__/useThrottledCallback-test.js | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 packages/react-dom/src/__tests__/useThrottledCallback-test.js diff --git a/packages/react-dom/src/__tests__/useThrottledCallback-test.js b/packages/react-dom/src/__tests__/useThrottledCallback-test.js new file mode 100644 index 000000000000..f4433a5b5bf5 --- /dev/null +++ b/packages/react-dom/src/__tests__/useThrottledCallback-test.js @@ -0,0 +1,331 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOM; +let act; +let useThrottledCallback; + +describe('useThrottledCallback', () => { + beforeEach(() => { + jest.useFakeTimers(); + React = require('react'); + ReactDOM = require('react-dom'); + act = require('internal-test-utils').act; + useThrottledCallback = + require('../hooks/useThrottledCallback').default; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function TestComponent({callback, delay, options}) { + const throttled = useThrottledCallback(callback, delay, options); + return ( +
+
+ ); + } + + it('should invoke callback immediately on first call (leading edge)', async () => { + const spy = jest.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + const btn = container.querySelector('[data-testid="invoke"]'); + await act(() => { + btn.click(); + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('click'); + + document.body.removeChild(container); + }); + + it('should throttle subsequent calls within delay window', async () => { + const spy = jest.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + const btn = container.querySelector('[data-testid="invoke"]'); + + // First call fires immediately (leading) + await act(() => { + btn.click(); + }); + expect(spy).toHaveBeenCalledTimes(1); + + // Second call within delay window should be throttled + await act(() => { + btn.click(); + }); + // Still 1 because second call is deferred + expect(spy).toHaveBeenCalledTimes(1); + + // After delay, trailing invocation fires + await act(() => { + jest.advanceTimersByTime(200); + }); + expect(spy).toHaveBeenCalledTimes(2); + + document.body.removeChild(container); + }); + + it('should support leading-only mode', async () => { + const spy = jest.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + const btn = container.querySelector('[data-testid="invoke"]'); + + await act(() => { + btn.click(); + }); + expect(spy).toHaveBeenCalledTimes(1); + + await act(() => { + btn.click(); + }); + expect(spy).toHaveBeenCalledTimes(1); + + // After delay, no trailing fire + await act(() => { + jest.advanceTimersByTime(200); + }); + expect(spy).toHaveBeenCalledTimes(1); + + document.body.removeChild(container); + }); + + it('should support trailing-only mode', async () => { + const spy = jest.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + const btn = container.querySelector('[data-testid="invoke"]'); + + // First call should NOT fire immediately + await act(() => { + btn.click(); + }); + expect(spy).toHaveBeenCalledTimes(0); + + // After delay, trailing fires + await act(() => { + jest.advanceTimersByTime(200); + }); + expect(spy).toHaveBeenCalledTimes(1); + + document.body.removeChild(container); + }); + + it('should cancel pending invocations', async () => { + const spy = jest.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + const invokeBtn = container.querySelector('[data-testid="invoke"]'); + const cancelBtn = container.querySelector('[data-testid="cancel"]'); + + // First call fires immediately + await act(() => { + invokeBtn.click(); + }); + expect(spy).toHaveBeenCalledTimes(1); + + // Second call is deferred + await act(() => { + invokeBtn.click(); + }); + + // Cancel before trailing fires + await act(() => { + cancelBtn.click(); + }); + + await act(() => { + jest.advanceTimersByTime(200); + }); + // Should still be 1 because we cancelled + expect(spy).toHaveBeenCalledTimes(1); + + document.body.removeChild(container); + }); + + it('should flush pending invocations immediately', async () => { + const spy = jest.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + const invokeBtn = container.querySelector('[data-testid="invoke"]'); + const flushBtn = container.querySelector('[data-testid="flush"]'); + + await act(() => { + invokeBtn.click(); + }); + expect(spy).toHaveBeenCalledTimes(1); + + // Queue another call + await act(() => { + invokeBtn.click(); + }); + + // Flush should invoke immediately without waiting + await act(() => { + flushBtn.click(); + }); + expect(spy).toHaveBeenCalledTimes(2); + + document.body.removeChild(container); + }); + + it('should use latest callback without re-creating throttled fn', async () => { + let callbackValue = 'first'; + const spy = jest.fn(() => callbackValue); + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + const btn = container.querySelector('[data-testid="invoke"]'); + + await act(() => { + btn.click(); + }); + expect(spy).toHaveBeenCalledTimes(1); + + // Update callback via re-render + callbackValue = 'second'; + const spy2 = jest.fn(() => callbackValue); + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + // Wait for throttle to expire then invoke again + await act(() => { + jest.advanceTimersByTime(100); + }); + + await act(() => { + btn.click(); + }); + expect(spy2).toHaveBeenCalledTimes(1); + + document.body.removeChild(container); + }); + + it('should cleanup timers on unmount', async () => { + const spy = jest.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(() => { + ReactDOM.render( + , + container, + ); + }); + + const btn = container.querySelector('[data-testid="invoke"]'); + + await act(() => { + btn.click(); + }); + expect(spy).toHaveBeenCalledTimes(1); + + // Queue trailing call + await act(() => { + btn.click(); + }); + + // Unmount before trailing fires + await act(() => { + ReactDOM.unmountComponentAtNode(container); + }); + + // Timer fires but component is unmounted - should not throw + await act(() => { + jest.advanceTimersByTime(200); + }); + expect(spy).toHaveBeenCalledTimes(1); + + document.body.removeChild(container); + }); +});