Skip to content

Commit 286f77a

Browse files
committed
fix(batch): run event-triggered changes immediately
When react-easy-state changes are triggered from the main event loop, they must run inside the main event loop, instead of being batched in the microtask. Otherwise, the cursor will jump to the end of input elements, since the change can't be tied back to the action taken. React batches changes caused inside an event loop, so we can rely on the normal React setState batching.
1 parent f3038a2 commit 286f77a

File tree

2 files changed

+73
-9
lines changed

2 files changed

+73
-9
lines changed

__tests__/batching.test.jsx

-8
Original file line numberDiff line numberDiff line change
@@ -254,14 +254,6 @@ describe('batching', () => {
254254

255255
expect(container).toHaveTextContent('2');
256256
expect(renderCount).toBe(2);
257-
258-
easyAct(() => {
259-
fireEvent.click(button);
260-
fireEvent.click(button);
261-
});
262-
263-
expect(container).toHaveTextContent('6');
264-
expect(renderCount).toBe(3);
265257
});
266258

267259
// TODO: batching native event handlers causes in input caret jumping bug

src/view.js

+73-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
isObservable,
1111
} from '@nx-js/observer-util';
1212

13-
import { hasHooks } from './utils';
13+
import { globalObj, hasHooks } from './utils';
1414

1515
export let isInsideFunctionComponent = false;
1616
export let isInsideClassComponentRender = false;
@@ -32,10 +32,25 @@ function mapStateToStores(state) {
3232
// is to prevent excessive rendering in situations where updates can occur
3333
// outside of React's built-in batching. e.g. after resolving a promise,
3434
// in a setTimeout callback, in an event handler.
35+
//
36+
// NOTE: This should be revisited after React improves batching for
37+
// Suspense / etc.
3538
let batchesPending = {};
3639
let taskPending = false;
3740
let viewIndexCounter = 0;
41+
let inEventLoop = false;
42+
3843
function batchSetState(viewIndex, fn) {
44+
if (inEventLoop) {
45+
// If we are in the main event loop, React handles the batching
46+
// automatically, so we run the change immediately. Deferring the
47+
// update can cause unexpected cursor shifts in input elements,
48+
// since the change can't be tied back to the action:
49+
// https://github.com/facebook/react/issues/5386
50+
fn();
51+
return;
52+
}
53+
3954
batchesPending[viewIndex] = fn;
4055
if (!taskPending) {
4156
taskPending = true;
@@ -57,6 +72,63 @@ function clearBatch(viewIndex) {
5772
delete batchesPending[viewIndex];
5873
}
5974

75+
// this creates and returns a wrapped version of the passed function
76+
// the cache is necessary to always map the same thing to the same function
77+
// which makes sure that addEventListener/removeEventListener pairs don't break
78+
const cache = new WeakMap();
79+
function wrapFn(fn, wrapper) {
80+
if (typeof fn !== 'function') {
81+
return fn;
82+
}
83+
let wrapped = cache.get(fn);
84+
if (!wrapped) {
85+
wrapped = function(...args) {
86+
return wrapper(fn, this, args);
87+
};
88+
cache.set(fn, wrapped);
89+
}
90+
return wrapped;
91+
}
92+
93+
function wrapMethodCallbacks(obj, method, wrapper) {
94+
const descriptor = Object.getOwnPropertyDescriptor(obj, method);
95+
if (
96+
descriptor &&
97+
descriptor.writable &&
98+
typeof descriptor.value === 'function'
99+
) {
100+
obj[method] = new Proxy(descriptor.value, {
101+
apply(target, ctx, args) {
102+
return Reflect.apply(
103+
target,
104+
ctx,
105+
args.map(f => wrapFn(f, wrapper)),
106+
);
107+
},
108+
});
109+
}
110+
}
111+
112+
// wrapped obj.addEventListener(cb) like callbacks
113+
function wrapMethodsCallbacks(obj, methods, wrapper) {
114+
methods.forEach(method =>
115+
wrapMethodCallbacks(obj, method, wrapper),
116+
);
117+
}
118+
119+
// batch addEventListener calls
120+
if (globalObj.EventTarget) {
121+
wrapMethodsCallbacks(
122+
EventTarget.prototype,
123+
['addEventListener', 'removeEventListener'],
124+
(fn, ctx, args) => {
125+
inEventLoop = true;
126+
fn.apply(ctx, args);
127+
inEventLoop = false;
128+
},
129+
);
130+
}
131+
60132
export function view(Comp) {
61133
const isStatelessComp = !(
62134
Comp.prototype && Comp.prototype.isReactComponent

0 commit comments

Comments
 (0)