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/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); + }); +}); 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(); + }, + }; +} 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 { 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