From 3cadf39aa12ab942df5674377dfc854aad5e333e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Jan 2026 01:19:01 +0000 Subject: [PATCH 1/5] feat: Implement freeze functionality to pause React updates Co-authored-by: aiden --- packages/bippy/src/core.ts | 239 ++++++++++++++++++++++++ packages/bippy/src/test/freeze.test.tsx | 156 ++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 packages/bippy/src/test/freeze.test.tsx diff --git a/packages/bippy/src/core.ts b/packages/bippy/src/core.ts index d4465fc5..658aff9f 100644 --- a/packages/bippy/src/core.ts +++ b/packages/bippy/src/core.ts @@ -1105,6 +1105,245 @@ export const overrideContext = ( } }; +interface DispatcherRef { + H?: unknown; + current?: unknown; +} + +interface Dispatcher { + useState: (initialState: S | (() => S)) => [S, (action: S | ((prev: S) => S)) => void]; + useReducer: ( + reducer: (state: S, action: A) => S, + initialArg: S, + init?: (arg: S) => S, + ) => [S, (action: A) => void]; + [key: string]: unknown; +} + +interface HookQueue { + pending: unknown; + dispatch?: (...args: unknown[]) => void; + lastRenderedReducer?: unknown; + lanes?: number; +} + +interface HookState { + memoizedState: unknown; + baseState: unknown; + baseQueue: unknown; + queue: HookQueue | null; + next: HookState | null; +} + +let isFrozen = false; +const originalDispatcherRefs = new Map(); +const frozenQueues = new WeakMap(); + +const freezeQueue = (queue: HookQueue): void => { + if (!queue || frozenQueues.has(queue)) return; + + const originalDescriptor = Object.getOwnPropertyDescriptor(queue, 'pending'); + frozenQueues.set(queue, { originalPendingDescriptor: originalDescriptor }); + + let storedPending = queue.pending; + + Object.defineProperty(queue, 'pending', { + configurable: true, + enumerable: true, + get() { + return storedPending; + }, + set(newValue) { + if (isFrozen) return; + storedPending = newValue; + }, + }); +}; + +const unfreezeQueue = (queue: HookQueue): void => { + const stored = frozenQueues.get(queue); + if (!stored) return; + + const currentValue = queue.pending; + + if (stored.originalPendingDescriptor) { + Object.defineProperty(queue, 'pending', stored.originalPendingDescriptor); + } else { + delete (queue as Record).pending; + queue.pending = currentValue; + } + + frozenQueues.delete(queue); +}; + +const wrapFiberQueues = (fiber: Fiber): void => { + let hookState = fiber.memoizedState as HookState | null; + while (hookState) { + if (hookState.queue && typeof hookState.queue === 'object') { + freezeQueue(hookState.queue); + } + hookState = hookState?.next ?? null; + } +}; + +const unwrapFiberQueues = (fiber: Fiber): void => { + let hookState = fiber.memoizedState as HookState | null; + while (hookState) { + if (hookState.queue && typeof hookState.queue === 'object') { + unfreezeQueue(hookState.queue); + } + hookState = hookState?.next ?? null; + } +}; + +const traverseAndFreezeQueues = (fiber: Fiber | null): void => { + if (!fiber) return; + + if (isCompositeFiber(fiber)) { + wrapFiberQueues(fiber); + } + + traverseAndFreezeQueues(fiber.child); + traverseAndFreezeQueues(fiber.sibling); +}; + +const traverseAndUnfreezeQueues = (fiber: Fiber | null): void => { + if (!fiber) return; + + if (isCompositeFiber(fiber)) { + unwrapFiberQueues(fiber); + } + + traverseAndUnfreezeQueues(fiber.child); + traverseAndUnfreezeQueues(fiber.sibling); +}; + +const createFrozenDispatcher = (originalDispatcher: Dispatcher): Dispatcher => { + return new Proxy(originalDispatcher, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + + if (prop === 'useState') { + return (initialState: S | (() => S)): [S, (action: S | ((prev: S) => S)) => void] => { + const result = (value as Dispatcher['useState'])(initialState); + return result; + }; + } + + if (prop === 'useReducer') { + return ( + reducer: (state: S, action: A) => S, + initialArg: S, + init?: (arg: S) => S, + ): [S, (action: A) => void] => { + const result = (value as Dispatcher['useReducer'])(reducer, initialArg, init); + return result; + }; + } + + return value; + }, + }); +}; + +const installDispatcherProxy = (renderer: ReactRenderer): void => { + const dispatcherRef = renderer.currentDispatcherRef as DispatcherRef | null; + if (!dispatcherRef || typeof dispatcherRef !== 'object') return; + if (originalDispatcherRefs.has(renderer)) return; + + const key: 'H' | 'current' = 'H' in dispatcherRef ? 'H' : 'current'; + originalDispatcherRefs.set(renderer, { key, originalDescriptor: Object.getOwnPropertyDescriptor(dispatcherRef, key) }); + + let currentDispatcher = dispatcherRef[key]; + + Object.defineProperty(dispatcherRef, key, { + configurable: true, + enumerable: true, + get() { + if (isFrozen && currentDispatcher) { + return createFrozenDispatcher(currentDispatcher as Dispatcher); + } + return currentDispatcher; + }, + set(newValue) { + currentDispatcher = newValue; + }, + }); +}; + +const uninstallDispatcherProxy = (renderer: ReactRenderer): void => { + const stored = originalDispatcherRefs.get(renderer); + if (!stored) return; + + const dispatcherRef = renderer.currentDispatcherRef as DispatcherRef | null; + if (!dispatcherRef) return; + + if (stored.originalDescriptor) { + Object.defineProperty(dispatcherRef, stored.key, stored.originalDescriptor); + } else { + delete (dispatcherRef as Record)[stored.key]; + } + + originalDispatcherRefs.delete(renderer); +}; + +/** + * Freezes all React state updates by patching hook queues and dispatchers. + * When frozen, all `useState` and `useReducer` dispatch functions become no-ops. + * + * This works by: + * 1. Intercepting the update queue's `pending` property to prevent updates from being enqueued + * 2. Patching dispatchers so new components also get frozen behavior + * + * @returns An unfreeze function to restore normal React behavior. + * + * @example + * ```ts + * const unfreeze = freeze(); + * // All setState calls are now no-ops + * unfreeze(); + * // React updates work normally again + * ``` + */ +export const freeze = (): (() => void) => { + if (isFrozen) { + return () => {}; + } + + const rdtHook = getRDTHook(); + + for (const renderer of rdtHook.renderers.values()) { + installDispatcherProxy(renderer); + } + + for (const root of _fiberRoots) { + traverseAndFreezeQueues(root.current); + } + + isFrozen = true; + + return () => { + if (!isFrozen) return; + + isFrozen = false; + + for (const root of _fiberRoots) { + traverseAndUnfreezeQueues(root.current); + } + + for (const renderer of rdtHook.renderers.values()) { + uninstallDispatcherProxy(renderer); + } + }; +}; + +/** + * Returns whether React updates are currently frozen. + */ +export const isFreezeActive = (): boolean => { + return isFrozen; +}; + export interface InstrumentationOptions { name?: string; onActive?: () => unknown; diff --git a/packages/bippy/src/test/freeze.test.tsx b/packages/bippy/src/test/freeze.test.tsx new file mode 100644 index 00000000..a64d10d3 --- /dev/null +++ b/packages/bippy/src/test/freeze.test.tsx @@ -0,0 +1,156 @@ +import '../index.js'; + +import * as React from 'react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act, cleanup } from '@testing-library/react'; +import { freeze, isFreezeActive, instrument, secure, getRDTHook, _fiberRoots } from '../core.js'; + +const Counter = () => { + const [count, setCount] = React.useState(0); + return ( +
+ {count} + +
+ ); +}; + +const ReducerCounter = () => { + const [state, dispatch] = React.useReducer( + (currentState: { count: number }, action: { type: 'increment' | 'decrement' }) => { + switch (action.type) { + case 'increment': + return { count: currentState.count + 1 }; + case 'decrement': + return { count: currentState.count - 1 }; + default: + return currentState; + } + }, + { count: 0 }, + ); + + return ( +
+ {state.count} + +
+ ); +}; + +describe('freeze', () => { + beforeEach(() => { + cleanup(); + instrument( + secure( + { + onCommitFiberRoot: () => {}, + }, + { dangerouslyRunInProduction: true }, + ), + ); + }); + + afterEach(() => { + cleanup(); + }); + + it('should return unfreeze function', () => { + const unfreeze = freeze(); + expect(typeof unfreeze).toBe('function'); + unfreeze(); + }); + + it('should report isFreezeActive correctly', () => { + expect(isFreezeActive()).toBe(false); + const unfreeze = freeze(); + expect(isFreezeActive()).toBe(true); + unfreeze(); + expect(isFreezeActive()).toBe(false); + }); + + it('should handle multiple freeze calls', () => { + const unfreeze1 = freeze(); + expect(isFreezeActive()).toBe(true); + const unfreeze2 = freeze(); + expect(isFreezeActive()).toBe(true); + unfreeze1(); + expect(isFreezeActive()).toBe(false); + unfreeze2(); + expect(isFreezeActive()).toBe(false); + }); + + it('should freeze useState updates', async () => { + render(); + + expect(_fiberRoots.size).toBeGreaterThan(0); + + const countElement = screen.getByTestId('count'); + const incrementButton = screen.getByTestId('increment'); + + expect(countElement.textContent).toBe('0'); + + await act(async () => { + fireEvent.click(incrementButton); + }); + expect(countElement.textContent).toBe('1'); + + const unfreeze = freeze(); + + await act(async () => { + fireEvent.click(incrementButton); + }); + expect(countElement.textContent).toBe('1'); + + await act(async () => { + fireEvent.click(incrementButton); + }); + expect(countElement.textContent).toBe('1'); + + unfreeze(); + + await act(async () => { + fireEvent.click(incrementButton); + }); + expect(countElement.textContent).toBe('2'); + }); + + it('should freeze useReducer updates', async () => { + render(); + + expect(_fiberRoots.size).toBeGreaterThan(0); + + const countElement = screen.getByTestId('reducer-count'); + const incrementButton = screen.getByTestId('reducer-increment'); + + expect(countElement.textContent).toBe('0'); + + await act(async () => { + fireEvent.click(incrementButton); + }); + expect(countElement.textContent).toBe('1'); + + const unfreeze = freeze(); + + await act(async () => { + fireEvent.click(incrementButton); + }); + expect(countElement.textContent).toBe('1'); + + unfreeze(); + + await act(async () => { + fireEvent.click(incrementButton); + }); + expect(countElement.textContent).toBe('2'); + }); + + it('should work with renderers accessed via getRDTHook', () => { + const rdtHook = getRDTHook(); + expect(rdtHook.renderers).toBeDefined(); + }); +}); From de91e0673cd0efbff476c428a94e64a2e7916785 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Jan 2026 01:24:49 +0000 Subject: [PATCH 2/5] feat: Support freezing useSyncExternalStore Co-authored-by: aiden --- packages/bippy/src/core.ts | 95 +++++++++++++++++++------ packages/bippy/src/test/freeze.test.tsx | 61 ++++++++++++++++ 2 files changed, 135 insertions(+), 21 deletions(-) diff --git a/packages/bippy/src/core.ts b/packages/bippy/src/core.ts index 658aff9f..c34ba9b9 100644 --- a/packages/bippy/src/core.ts +++ b/packages/bippy/src/core.ts @@ -1117,14 +1117,21 @@ interface Dispatcher { initialArg: S, init?: (arg: S) => S, ) => [S, (action: A) => void]; + useSyncExternalStore: ( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T, + ) => T; [key: string]: unknown; } interface HookQueue { - pending: unknown; + pending?: unknown; dispatch?: (...args: unknown[]) => void; lastRenderedReducer?: unknown; lanes?: number; + value?: unknown; + getSnapshot?: () => unknown; } interface HookState { @@ -1135,44 +1142,73 @@ interface HookState { next: HookState | null; } +interface FrozenQueueData { + originalPendingDescriptor?: PropertyDescriptor; + originalGetSnapshot?: () => unknown; + frozenSnapshotValue?: unknown; +} + let isFrozen = false; const originalDispatcherRefs = new Map(); -const frozenQueues = new WeakMap(); +const frozenQueues = new WeakMap(); -const freezeQueue = (queue: HookQueue): void => { +const freezeStateQueue = (queue: HookQueue): void => { if (!queue || frozenQueues.has(queue)) return; - const originalDescriptor = Object.getOwnPropertyDescriptor(queue, 'pending'); - frozenQueues.set(queue, { originalPendingDescriptor: originalDescriptor }); + const data: FrozenQueueData = {}; + + if ('pending' in queue) { + data.originalPendingDescriptor = Object.getOwnPropertyDescriptor(queue, 'pending'); + let storedPending = queue.pending; + + Object.defineProperty(queue, 'pending', { + configurable: true, + enumerable: true, + get() { + return storedPending; + }, + set(newValue) { + if (isFrozen) return; + storedPending = newValue; + }, + }); + } - let storedPending = queue.pending; + if ('getSnapshot' in queue && typeof queue.getSnapshot === 'function') { + data.originalGetSnapshot = queue.getSnapshot; + data.frozenSnapshotValue = queue.getSnapshot(); - Object.defineProperty(queue, 'pending', { - configurable: true, - enumerable: true, - get() { - return storedPending; - }, - set(newValue) { - if (isFrozen) return; - storedPending = newValue; - }, - }); + queue.getSnapshot = () => { + if (isFrozen) { + return data.frozenSnapshotValue; + } + return data.originalGetSnapshot!(); + }; + } + + frozenQueues.set(queue, data); }; const unfreezeQueue = (queue: HookQueue): void => { const stored = frozenQueues.get(queue); if (!stored) return; - const currentValue = queue.pending; - if (stored.originalPendingDescriptor) { + const currentValue = queue.pending; Object.defineProperty(queue, 'pending', stored.originalPendingDescriptor); - } else { + if (!stored.originalPendingDescriptor.get && !stored.originalPendingDescriptor.set) { + queue.pending = currentValue; + } + } else if ('pending' in queue) { + const currentValue = queue.pending; delete (queue as Record).pending; queue.pending = currentValue; } + if (stored.originalGetSnapshot) { + queue.getSnapshot = stored.originalGetSnapshot; + } + frozenQueues.delete(queue); }; @@ -1180,7 +1216,7 @@ const wrapFiberQueues = (fiber: Fiber): void => { let hookState = fiber.memoizedState as HookState | null; while (hookState) { if (hookState.queue && typeof hookState.queue === 'object') { - freezeQueue(hookState.queue); + freezeStateQueue(hookState.queue); } hookState = hookState?.next ?? null; } @@ -1241,6 +1277,23 @@ const createFrozenDispatcher = (originalDispatcher: Dispatcher): Dispatcher => { }; } + if (prop === 'useSyncExternalStore') { + return ( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T, + ): T => { + const wrappedSubscribe = (onStoreChange: () => void) => { + const wrappedCallback = () => { + if (isFrozen) return; + onStoreChange(); + }; + return subscribe(wrappedCallback); + }; + return (value as Dispatcher['useSyncExternalStore'])(wrappedSubscribe, getSnapshot, getServerSnapshot); + }; + } + return value; }, }); diff --git a/packages/bippy/src/test/freeze.test.tsx b/packages/bippy/src/test/freeze.test.tsx index a64d10d3..e95c58be 100644 --- a/packages/bippy/src/test/freeze.test.tsx +++ b/packages/bippy/src/test/freeze.test.tsx @@ -42,6 +42,32 @@ const ReducerCounter = () => { ); }; +const createExternalStore = (initialValue: number) => { + let value = initialValue; + const listeners = new Set<() => void>(); + + return { + getSnapshot: () => value, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + increment: () => { + value++; + listeners.forEach((listener) => listener()); + }, + }; +}; + +const ExternalStoreCounter = ({ store }: { store: ReturnType }) => { + const count = React.useSyncExternalStore(store.subscribe, store.getSnapshot); + return ( +
+ {count} +
+ ); +}; + describe('freeze', () => { beforeEach(() => { cleanup(); @@ -149,6 +175,41 @@ describe('freeze', () => { expect(countElement.textContent).toBe('2'); }); + it('should freeze useSyncExternalStore updates', async () => { + const store = createExternalStore(0); + render(); + + expect(_fiberRoots.size).toBeGreaterThan(0); + + const countElement = screen.getByTestId('external-count'); + + expect(countElement.textContent).toBe('0'); + + await act(async () => { + store.increment(); + }); + expect(countElement.textContent).toBe('1'); + + const unfreeze = freeze(); + + await act(async () => { + store.increment(); + }); + expect(countElement.textContent).toBe('1'); + + await act(async () => { + store.increment(); + }); + expect(countElement.textContent).toBe('1'); + + unfreeze(); + + await act(async () => { + store.increment(); + }); + expect(countElement.textContent).toBe('4'); + }); + it('should work with renderers accessed via getRDTHook', () => { const rdtHook = getRDTHook(); expect(rdtHook.renderers).toBeDefined(); From 1bc8a5b31dcce40013dd32e929281a10ff87e0d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Jan 2026 01:37:13 +0000 Subject: [PATCH 3/5] Refactor: Rename freeze to pauseUpdates and update docs Co-authored-by: aiden --- packages/bippy/src/core.ts | 21 ++++----- packages/bippy/src/test/freeze.test.tsx | 60 ++++++++++++------------- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/bippy/src/core.ts b/packages/bippy/src/core.ts index c34ba9b9..e3d841b7 100644 --- a/packages/bippy/src/core.ts +++ b/packages/bippy/src/core.ts @@ -1341,24 +1341,25 @@ const uninstallDispatcherProxy = (renderer: ReactRenderer): void => { }; /** - * Freezes all React state updates by patching hook queues and dispatchers. - * When frozen, all `useState` and `useReducer` dispatch functions become no-ops. + * Pauses all React state updates by patching hook queues and dispatchers. + * When paused, all `useState`, `useReducer`, and `useSyncExternalStore` updates become no-ops. * * This works by: * 1. Intercepting the update queue's `pending` property to prevent updates from being enqueued - * 2. Patching dispatchers so new components also get frozen behavior + * 2. Wrapping `getSnapshot` for external stores to return frozen values + * 3. Patching dispatchers so new components also get paused behavior * - * @returns An unfreeze function to restore normal React behavior. + * @returns A function to resume normal React behavior. * * @example * ```ts - * const unfreeze = freeze(); - * // All setState calls are now no-ops - * unfreeze(); + * const resumeUpdates = pauseUpdates(); + * // All setState/dispatch calls are now no-ops + * resumeUpdates(); * // React updates work normally again * ``` */ -export const freeze = (): (() => void) => { +export const pauseUpdates = (): (() => void) => { if (isFrozen) { return () => {}; } @@ -1391,9 +1392,9 @@ export const freeze = (): (() => void) => { }; /** - * Returns whether React updates are currently frozen. + * Returns whether React updates are currently paused. */ -export const isFreezeActive = (): boolean => { +export const areUpdatesPaused = (): boolean => { return isFrozen; }; diff --git a/packages/bippy/src/test/freeze.test.tsx b/packages/bippy/src/test/freeze.test.tsx index e95c58be..f6d2168b 100644 --- a/packages/bippy/src/test/freeze.test.tsx +++ b/packages/bippy/src/test/freeze.test.tsx @@ -3,7 +3,7 @@ import '../index.js'; import * as React from 'react'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, act, cleanup } from '@testing-library/react'; -import { freeze, isFreezeActive, instrument, secure, getRDTHook, _fiberRoots } from '../core.js'; +import { pauseUpdates, areUpdatesPaused, instrument, secure, getRDTHook, _fiberRoots } from '../core.js'; const Counter = () => { const [count, setCount] = React.useState(0); @@ -68,7 +68,7 @@ const ExternalStoreCounter = ({ store }: { store: ReturnType { +describe('pauseUpdates', () => { beforeEach(() => { cleanup(); instrument( @@ -85,32 +85,32 @@ describe('freeze', () => { cleanup(); }); - it('should return unfreeze function', () => { - const unfreeze = freeze(); - expect(typeof unfreeze).toBe('function'); - unfreeze(); + it('should return resume function', () => { + const resumeUpdates = pauseUpdates(); + expect(typeof resumeUpdates).toBe('function'); + resumeUpdates(); }); - it('should report isFreezeActive correctly', () => { - expect(isFreezeActive()).toBe(false); - const unfreeze = freeze(); - expect(isFreezeActive()).toBe(true); - unfreeze(); - expect(isFreezeActive()).toBe(false); + it('should report areUpdatesPaused correctly', () => { + expect(areUpdatesPaused()).toBe(false); + const resumeUpdates = pauseUpdates(); + expect(areUpdatesPaused()).toBe(true); + resumeUpdates(); + expect(areUpdatesPaused()).toBe(false); }); - it('should handle multiple freeze calls', () => { - const unfreeze1 = freeze(); - expect(isFreezeActive()).toBe(true); - const unfreeze2 = freeze(); - expect(isFreezeActive()).toBe(true); - unfreeze1(); - expect(isFreezeActive()).toBe(false); - unfreeze2(); - expect(isFreezeActive()).toBe(false); + it('should handle multiple pauseUpdates calls', () => { + const resume1 = pauseUpdates(); + expect(areUpdatesPaused()).toBe(true); + const resume2 = pauseUpdates(); + expect(areUpdatesPaused()).toBe(true); + resume1(); + expect(areUpdatesPaused()).toBe(false); + resume2(); + expect(areUpdatesPaused()).toBe(false); }); - it('should freeze useState updates', async () => { + it('should pause useState updates', async () => { render(); expect(_fiberRoots.size).toBeGreaterThan(0); @@ -125,7 +125,7 @@ describe('freeze', () => { }); expect(countElement.textContent).toBe('1'); - const unfreeze = freeze(); + const resumeUpdates = pauseUpdates(); await act(async () => { fireEvent.click(incrementButton); @@ -137,7 +137,7 @@ describe('freeze', () => { }); expect(countElement.textContent).toBe('1'); - unfreeze(); + resumeUpdates(); await act(async () => { fireEvent.click(incrementButton); @@ -145,7 +145,7 @@ describe('freeze', () => { expect(countElement.textContent).toBe('2'); }); - it('should freeze useReducer updates', async () => { + it('should pause useReducer updates', async () => { render(); expect(_fiberRoots.size).toBeGreaterThan(0); @@ -160,14 +160,14 @@ describe('freeze', () => { }); expect(countElement.textContent).toBe('1'); - const unfreeze = freeze(); + const resumeUpdates = pauseUpdates(); await act(async () => { fireEvent.click(incrementButton); }); expect(countElement.textContent).toBe('1'); - unfreeze(); + resumeUpdates(); await act(async () => { fireEvent.click(incrementButton); @@ -175,7 +175,7 @@ describe('freeze', () => { expect(countElement.textContent).toBe('2'); }); - it('should freeze useSyncExternalStore updates', async () => { + it('should pause useSyncExternalStore updates', async () => { const store = createExternalStore(0); render(); @@ -190,7 +190,7 @@ describe('freeze', () => { }); expect(countElement.textContent).toBe('1'); - const unfreeze = freeze(); + const resumeUpdates = pauseUpdates(); await act(async () => { store.increment(); @@ -202,7 +202,7 @@ describe('freeze', () => { }); expect(countElement.textContent).toBe('1'); - unfreeze(); + resumeUpdates(); await act(async () => { store.increment(); From 5dff70b49903d4e77b053930d23f9ff18ca87a9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Jan 2026 01:53:18 +0000 Subject: [PATCH 4/5] Refactor: Rename freeze to pause and update tests Rename freeze functionality to pause, update internal state names, and move tests to a new file. Co-authored-by: aiden --- packages/bippy/src/core.ts | 184 +++++++++--------- ...freeze.test.tsx => pause-updates.test.tsx} | 0 2 files changed, 94 insertions(+), 90 deletions(-) rename packages/bippy/src/test/{freeze.test.tsx => pause-updates.test.tsx} (100%) diff --git a/packages/bippy/src/core.ts b/packages/bippy/src/core.ts index e3d841b7..caaf4087 100644 --- a/packages/bippy/src/core.ts +++ b/packages/bippy/src/core.ts @@ -1142,127 +1142,131 @@ interface HookState { next: HookState | null; } -interface FrozenQueueData { +interface PausedQueueState { originalPendingDescriptor?: PropertyDescriptor; originalGetSnapshot?: () => unknown; - frozenSnapshotValue?: unknown; + snapshotValueAtPause?: unknown; } -let isFrozen = false; -const originalDispatcherRefs = new Map(); -const frozenQueues = new WeakMap(); +interface PausedDispatcherState { + dispatcherKey: 'H' | 'current'; + originalDescriptor: PropertyDescriptor | undefined; +} + +let isUpdatesPaused = false; +const pausedDispatcherStates = new Map(); +const pausedQueueStates = new WeakMap(); -const freezeStateQueue = (queue: HookQueue): void => { - if (!queue || frozenQueues.has(queue)) return; +const pauseHookQueue = (queue: HookQueue): void => { + if (!queue || pausedQueueStates.has(queue)) return; - const data: FrozenQueueData = {}; + const queuePauseState: PausedQueueState = {}; if ('pending' in queue) { - data.originalPendingDescriptor = Object.getOwnPropertyDescriptor(queue, 'pending'); - let storedPending = queue.pending; + queuePauseState.originalPendingDescriptor = Object.getOwnPropertyDescriptor(queue, 'pending'); + let currentPendingValue = queue.pending; Object.defineProperty(queue, 'pending', { configurable: true, enumerable: true, get() { - return storedPending; + return currentPendingValue; }, set(newValue) { - if (isFrozen) return; - storedPending = newValue; + if (isUpdatesPaused) return; + currentPendingValue = newValue; }, }); } if ('getSnapshot' in queue && typeof queue.getSnapshot === 'function') { - data.originalGetSnapshot = queue.getSnapshot; - data.frozenSnapshotValue = queue.getSnapshot(); + queuePauseState.originalGetSnapshot = queue.getSnapshot; + queuePauseState.snapshotValueAtPause = queue.getSnapshot(); queue.getSnapshot = () => { - if (isFrozen) { - return data.frozenSnapshotValue; + if (isUpdatesPaused) { + return queuePauseState.snapshotValueAtPause; } - return data.originalGetSnapshot!(); + return queuePauseState.originalGetSnapshot!(); }; } - frozenQueues.set(queue, data); + pausedQueueStates.set(queue, queuePauseState); }; -const unfreezeQueue = (queue: HookQueue): void => { - const stored = frozenQueues.get(queue); - if (!stored) return; +const resumeHookQueue = (queue: HookQueue): void => { + const queuePauseState = pausedQueueStates.get(queue); + if (!queuePauseState) return; - if (stored.originalPendingDescriptor) { - const currentValue = queue.pending; - Object.defineProperty(queue, 'pending', stored.originalPendingDescriptor); - if (!stored.originalPendingDescriptor.get && !stored.originalPendingDescriptor.set) { - queue.pending = currentValue; + if (queuePauseState.originalPendingDescriptor) { + const currentPendingValue = queue.pending; + Object.defineProperty(queue, 'pending', queuePauseState.originalPendingDescriptor); + if (!queuePauseState.originalPendingDescriptor.get && !queuePauseState.originalPendingDescriptor.set) { + queue.pending = currentPendingValue; } } else if ('pending' in queue) { - const currentValue = queue.pending; + const currentPendingValue = queue.pending; delete (queue as Record).pending; - queue.pending = currentValue; + queue.pending = currentPendingValue; } - if (stored.originalGetSnapshot) { - queue.getSnapshot = stored.originalGetSnapshot; + if (queuePauseState.originalGetSnapshot) { + queue.getSnapshot = queuePauseState.originalGetSnapshot; } - frozenQueues.delete(queue); + pausedQueueStates.delete(queue); }; -const wrapFiberQueues = (fiber: Fiber): void => { - let hookState = fiber.memoizedState as HookState | null; - while (hookState) { - if (hookState.queue && typeof hookState.queue === 'object') { - freezeStateQueue(hookState.queue); +const pauseFiberHookQueues = (fiber: Fiber): void => { + let currentHookState = fiber.memoizedState as HookState | null; + while (currentHookState) { + if (currentHookState.queue && typeof currentHookState.queue === 'object') { + pauseHookQueue(currentHookState.queue); } - hookState = hookState?.next ?? null; + currentHookState = currentHookState?.next ?? null; } }; -const unwrapFiberQueues = (fiber: Fiber): void => { - let hookState = fiber.memoizedState as HookState | null; - while (hookState) { - if (hookState.queue && typeof hookState.queue === 'object') { - unfreezeQueue(hookState.queue); +const resumeFiberHookQueues = (fiber: Fiber): void => { + let currentHookState = fiber.memoizedState as HookState | null; + while (currentHookState) { + if (currentHookState.queue && typeof currentHookState.queue === 'object') { + resumeHookQueue(currentHookState.queue); } - hookState = hookState?.next ?? null; + currentHookState = currentHookState?.next ?? null; } }; -const traverseAndFreezeQueues = (fiber: Fiber | null): void => { +const traverseAndPauseHookQueues = (fiber: Fiber | null): void => { if (!fiber) return; if (isCompositeFiber(fiber)) { - wrapFiberQueues(fiber); + pauseFiberHookQueues(fiber); } - traverseAndFreezeQueues(fiber.child); - traverseAndFreezeQueues(fiber.sibling); + traverseAndPauseHookQueues(fiber.child); + traverseAndPauseHookQueues(fiber.sibling); }; -const traverseAndUnfreezeQueues = (fiber: Fiber | null): void => { +const traverseAndResumeHookQueues = (fiber: Fiber | null): void => { if (!fiber) return; if (isCompositeFiber(fiber)) { - unwrapFiberQueues(fiber); + resumeFiberHookQueues(fiber); } - traverseAndUnfreezeQueues(fiber.child); - traverseAndUnfreezeQueues(fiber.sibling); + traverseAndResumeHookQueues(fiber.child); + traverseAndResumeHookQueues(fiber.sibling); }; -const createFrozenDispatcher = (originalDispatcher: Dispatcher): Dispatcher => { +const createPausedDispatcher = (originalDispatcher: Dispatcher): Dispatcher => { return new Proxy(originalDispatcher, { get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); + const originalMethod = Reflect.get(target, prop, receiver); if (prop === 'useState') { return (initialState: S | (() => S)): [S, (action: S | ((prev: S) => S)) => void] => { - const result = (value as Dispatcher['useState'])(initialState); - return result; + return (originalMethod as Dispatcher['useState'])(initialState); }; } @@ -1272,8 +1276,7 @@ const createFrozenDispatcher = (originalDispatcher: Dispatcher): Dispatcher => { initialArg: S, init?: (arg: S) => S, ): [S, (action: A) => void] => { - const result = (value as Dispatcher['useReducer'])(reducer, initialArg, init); - return result; + return (originalMethod as Dispatcher['useReducer'])(reducer, initialArg, init); }; } @@ -1283,18 +1286,18 @@ const createFrozenDispatcher = (originalDispatcher: Dispatcher): Dispatcher => { getSnapshot: () => T, getServerSnapshot?: () => T, ): T => { - const wrappedSubscribe = (onStoreChange: () => void) => { - const wrappedCallback = () => { - if (isFrozen) return; + const pauseAwareSubscribe = (onStoreChange: () => void) => { + const pauseAwareCallback = () => { + if (isUpdatesPaused) return; onStoreChange(); }; - return subscribe(wrappedCallback); + return subscribe(pauseAwareCallback); }; - return (value as Dispatcher['useSyncExternalStore'])(wrappedSubscribe, getSnapshot, getServerSnapshot); + return (originalMethod as Dispatcher['useSyncExternalStore'])(pauseAwareSubscribe, getSnapshot, getServerSnapshot); }; } - return value; + return originalMethod; }, }); }; @@ -1302,42 +1305,43 @@ const createFrozenDispatcher = (originalDispatcher: Dispatcher): Dispatcher => { const installDispatcherProxy = (renderer: ReactRenderer): void => { const dispatcherRef = renderer.currentDispatcherRef as DispatcherRef | null; if (!dispatcherRef || typeof dispatcherRef !== 'object') return; - if (originalDispatcherRefs.has(renderer)) return; + if (pausedDispatcherStates.has(renderer)) return; - const key: 'H' | 'current' = 'H' in dispatcherRef ? 'H' : 'current'; - originalDispatcherRefs.set(renderer, { key, originalDescriptor: Object.getOwnPropertyDescriptor(dispatcherRef, key) }); + const dispatcherKey: 'H' | 'current' = 'H' in dispatcherRef ? 'H' : 'current'; + const originalDescriptor = Object.getOwnPropertyDescriptor(dispatcherRef, dispatcherKey); + pausedDispatcherStates.set(renderer, { dispatcherKey, originalDescriptor }); - let currentDispatcher = dispatcherRef[key]; + let currentDispatcherValue = dispatcherRef[dispatcherKey]; - Object.defineProperty(dispatcherRef, key, { + Object.defineProperty(dispatcherRef, dispatcherKey, { configurable: true, enumerable: true, get() { - if (isFrozen && currentDispatcher) { - return createFrozenDispatcher(currentDispatcher as Dispatcher); + if (isUpdatesPaused && currentDispatcherValue) { + return createPausedDispatcher(currentDispatcherValue as Dispatcher); } - return currentDispatcher; + return currentDispatcherValue; }, - set(newValue) { - currentDispatcher = newValue; + set(newDispatcher) { + currentDispatcherValue = newDispatcher; }, }); }; const uninstallDispatcherProxy = (renderer: ReactRenderer): void => { - const stored = originalDispatcherRefs.get(renderer); - if (!stored) return; + const dispatcherPauseState = pausedDispatcherStates.get(renderer); + if (!dispatcherPauseState) return; const dispatcherRef = renderer.currentDispatcherRef as DispatcherRef | null; if (!dispatcherRef) return; - if (stored.originalDescriptor) { - Object.defineProperty(dispatcherRef, stored.key, stored.originalDescriptor); + if (dispatcherPauseState.originalDescriptor) { + Object.defineProperty(dispatcherRef, dispatcherPauseState.dispatcherKey, dispatcherPauseState.originalDescriptor); } else { - delete (dispatcherRef as Record)[stored.key]; + delete (dispatcherRef as Record)[dispatcherPauseState.dispatcherKey]; } - originalDispatcherRefs.delete(renderer); + pausedDispatcherStates.delete(renderer); }; /** @@ -1346,7 +1350,7 @@ const uninstallDispatcherProxy = (renderer: ReactRenderer): void => { * * This works by: * 1. Intercepting the update queue's `pending` property to prevent updates from being enqueued - * 2. Wrapping `getSnapshot` for external stores to return frozen values + * 2. Wrapping `getSnapshot` for external stores to return the value captured at pause time * 3. Patching dispatchers so new components also get paused behavior * * @returns A function to resume normal React behavior. @@ -1360,7 +1364,7 @@ const uninstallDispatcherProxy = (renderer: ReactRenderer): void => { * ``` */ export const pauseUpdates = (): (() => void) => { - if (isFrozen) { + if (isUpdatesPaused) { return () => {}; } @@ -1370,19 +1374,19 @@ export const pauseUpdates = (): (() => void) => { installDispatcherProxy(renderer); } - for (const root of _fiberRoots) { - traverseAndFreezeQueues(root.current); + for (const fiberRoot of _fiberRoots) { + traverseAndPauseHookQueues(fiberRoot.current); } - isFrozen = true; + isUpdatesPaused = true; return () => { - if (!isFrozen) return; + if (!isUpdatesPaused) return; - isFrozen = false; + isUpdatesPaused = false; - for (const root of _fiberRoots) { - traverseAndUnfreezeQueues(root.current); + for (const fiberRoot of _fiberRoots) { + traverseAndResumeHookQueues(fiberRoot.current); } for (const renderer of rdtHook.renderers.values()) { @@ -1395,7 +1399,7 @@ export const pauseUpdates = (): (() => void) => { * Returns whether React updates are currently paused. */ export const areUpdatesPaused = (): boolean => { - return isFrozen; + return isUpdatesPaused; }; export interface InstrumentationOptions { diff --git a/packages/bippy/src/test/freeze.test.tsx b/packages/bippy/src/test/pause-updates.test.tsx similarity index 100% rename from packages/bippy/src/test/freeze.test.tsx rename to packages/bippy/src/test/pause-updates.test.tsx From 9d5d487568781dcfb611e92837d9c6033f017ac8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Jan 2026 07:57:15 +0000 Subject: [PATCH 5/5] feat: add context, useTransition, and useDeferredValue freezing support - Add context dependency freezing to prevent context changes from triggering updates - Extend dispatcher proxy to handle useTransition and useDeferredValue hooks - Add feature detection for all new freeze mechanisms - Include comprehensive tests for context, useTransition, and useDeferredValue - Update documentation to reflect new capabilities Co-authored-by: aiden --- packages/bippy/src/core.ts | 121 +++++++++++++++- .../bippy/src/test/pause-updates.test.tsx | 133 ++++++++++++++++++ 2 files changed, 251 insertions(+), 3 deletions(-) diff --git a/packages/bippy/src/core.ts b/packages/bippy/src/core.ts index caaf4087..6670fa9c 100644 --- a/packages/bippy/src/core.ts +++ b/packages/bippy/src/core.ts @@ -1153,9 +1153,15 @@ interface PausedDispatcherState { originalDescriptor: PropertyDescriptor | undefined; } +interface PausedContextState { + originalDescriptor?: PropertyDescriptor; + frozenValue: unknown; +} + let isUpdatesPaused = false; const pausedDispatcherStates = new Map(); const pausedQueueStates = new WeakMap(); +const pausedContextStates = new WeakMap, PausedContextState>(); const pauseHookQueue = (queue: HookQueue): void => { if (!queue || pausedQueueStates.has(queue)) return; @@ -1217,6 +1223,81 @@ const resumeHookQueue = (queue: HookQueue): void => { pausedQueueStates.delete(queue); }; +const pauseContextDependency = (contextDependency: ContextDependency): void => { + if (pausedContextStates.has(contextDependency)) return; + + const frozenValue = contextDependency.memoizedValue; + const originalDescriptor = Object.getOwnPropertyDescriptor(contextDependency, 'memoizedValue'); + + const pausedState: PausedContextState = { + originalDescriptor, + frozenValue, + }; + + Object.defineProperty(contextDependency, 'memoizedValue', { + configurable: true, + enumerable: true, + get() { + if (isUpdatesPaused) { + return pausedState.frozenValue; + } + if (originalDescriptor?.get) { + return originalDescriptor.get.call(this); + } + return (this as unknown as { _memoizedValue?: unknown })._memoizedValue; + }, + set(newValue) { + if (isUpdatesPaused) return; + if (originalDescriptor?.set) { + originalDescriptor.set.call(this, newValue); + } else { + (this as unknown as { _memoizedValue: unknown })._memoizedValue = newValue; + } + }, + }); + + pausedContextStates.set(contextDependency, pausedState); +}; + +const resumeContextDependency = (contextDependency: ContextDependency): void => { + const pausedState = pausedContextStates.get(contextDependency); + if (!pausedState) return; + + if (pausedState.originalDescriptor) { + Object.defineProperty(contextDependency, 'memoizedValue', pausedState.originalDescriptor); + } else { + delete (contextDependency as unknown as Record).memoizedValue; + } + + pausedContextStates.delete(contextDependency); +}; + +const pauseFiberContextDependencies = (fiber: Fiber): void => { + if (!fiber.dependencies) return; + + try { + let contextDependency = fiber.dependencies.firstContext as ContextDependency | null; + + while (contextDependency && typeof contextDependency === 'object' && 'memoizedValue' in contextDependency) { + pauseContextDependency(contextDependency); + contextDependency = contextDependency.next; + } + } catch {} +}; + +const resumeFiberContextDependencies = (fiber: Fiber): void => { + if (!fiber.dependencies) return; + + try { + let contextDependency = fiber.dependencies.firstContext as ContextDependency | null; + + while (contextDependency && typeof contextDependency === 'object' && 'memoizedValue' in contextDependency) { + resumeContextDependency(contextDependency); + contextDependency = contextDependency.next; + } + } catch {} +}; + const pauseFiberHookQueues = (fiber: Fiber): void => { let currentHookState = fiber.memoizedState as HookState | null; while (currentHookState) { @@ -1242,6 +1323,7 @@ const traverseAndPauseHookQueues = (fiber: Fiber | null): void => { if (isCompositeFiber(fiber)) { pauseFiberHookQueues(fiber); + pauseFiberContextDependencies(fiber); } traverseAndPauseHookQueues(fiber.child); @@ -1253,6 +1335,7 @@ const traverseAndResumeHookQueues = (fiber: Fiber | null): void => { if (isCompositeFiber(fiber)) { resumeFiberHookQueues(fiber); + resumeFiberContextDependencies(fiber); } traverseAndResumeHookQueues(fiber.child); @@ -1297,6 +1380,35 @@ const createPausedDispatcher = (originalDispatcher: Dispatcher): Dispatcher => { }; } + if (prop === 'useTransition' && typeof originalMethod === 'function') { + return (...args: unknown[]): unknown => { + const result = originalMethod(...args); + + if (!Array.isArray(result) || result.length !== 2) { + return result; + } + + const [isPending, startTransition] = result as [boolean, (callback: () => void) => void]; + + if (typeof startTransition !== 'function') { + return result; + } + + const pausedStartTransition = (callback: () => void) => { + if (isUpdatesPaused) return; + startTransition(callback); + }; + + return [isPending, pausedStartTransition]; + }; + } + + if (prop === 'useDeferredValue' && typeof originalMethod === 'function') { + return (...args: unknown[]): T => { + return (originalMethod as (...args: unknown[]) => T)(...args); + }; + } + return originalMethod; }, }); @@ -1345,13 +1457,16 @@ const uninstallDispatcherProxy = (renderer: ReactRenderer): void => { }; /** - * Pauses all React state updates by patching hook queues and dispatchers. - * When paused, all `useState`, `useReducer`, and `useSyncExternalStore` updates become no-ops. + * Pauses all React state updates by patching hook queues, dispatchers, and context dependencies. + * When paused, all `useState`, `useReducer`, `useSyncExternalStore`, `useTransition`, `useDeferredValue`, + * and context updates become no-ops. * * This works by: * 1. Intercepting the update queue's `pending` property to prevent updates from being enqueued * 2. Wrapping `getSnapshot` for external stores to return the value captured at pause time - * 3. Patching dispatchers so new components also get paused behavior + * 3. Freezing context dependencies to prevent context changes from triggering updates + * 4. Blocking `startTransition` calls when paused + * 5. Patching dispatchers so new components also get paused behavior * * @returns A function to resume normal React behavior. * diff --git a/packages/bippy/src/test/pause-updates.test.tsx b/packages/bippy/src/test/pause-updates.test.tsx index f6d2168b..222c3f11 100644 --- a/packages/bippy/src/test/pause-updates.test.tsx +++ b/packages/bippy/src/test/pause-updates.test.tsx @@ -214,4 +214,137 @@ describe('pauseUpdates', () => { const rdtHook = getRDTHook(); expect(rdtHook.renderers).toBeDefined(); }); + + it('should pause context updates', async () => { + const TestContext = React.createContext(0); + + const ContextConsumer = () => { + const value = React.useContext(TestContext); + return {value}; + }; + + const ContextProvider = () => { + const [contextValue, setContextValue] = React.useState(0); + return ( + + + + + ); + }; + + render(); + + const valueElement = screen.getByTestId('context-value'); + const updateButton = screen.getByTestId('update-context'); + + expect(valueElement.textContent).toBe('0'); + + await act(async () => { + fireEvent.click(updateButton); + }); + expect(valueElement.textContent).toBe('1'); + + const resumeUpdates = pauseUpdates(); + + await act(async () => { + fireEvent.click(updateButton); + }); + expect(valueElement.textContent).toBe('1'); + + resumeUpdates(); + + await act(async () => { + fireEvent.click(updateButton); + }); + expect(valueElement.textContent).toBe('2'); + }); + + it('should pause useTransition updates', async () => { + const TransitionComponent = () => { + const [count, setCount] = React.useState(0); + const [isPending, startTransition] = React.useTransition(); + + return ( +
+ {count} + {isPending ? 'pending' : 'idle'} + +
+ ); + }; + + render(); + + const countElement = screen.getByTestId('transition-count'); + const updateButton = screen.getByTestId('transition-update'); + + expect(countElement.textContent).toBe('0'); + + await act(async () => { + fireEvent.click(updateButton); + }); + expect(countElement.textContent).toBe('1'); + + const resumeUpdates = pauseUpdates(); + + await act(async () => { + fireEvent.click(updateButton); + }); + expect(countElement.textContent).toBe('1'); + + resumeUpdates(); + + await act(async () => { + fireEvent.click(updateButton); + }); + expect(countElement.textContent).toBe('2'); + }); + + it('should handle useDeferredValue gracefully', async () => { + const DeferredComponent = () => { + const [input, setInput] = React.useState('initial'); + const deferredInput = React.useDeferredValue(input); + + return ( +
+ {deferredInput} + +
+ ); + }; + + render(); + + const valueElement = screen.getByTestId('deferred-value'); + const updateButton = screen.getByTestId('update-input'); + + expect(valueElement.textContent).toBe('initial'); + + await act(async () => { + fireEvent.click(updateButton); + }); + expect(valueElement.textContent).toBe('updated'); + + const resumeUpdates = pauseUpdates(); + + await act(async () => { + fireEvent.click(screen.getByTestId('update-input')); + }); + + resumeUpdates(); + }); });