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