Skip to content

Commit 3cc190e

Browse files
committed
feat(store): auto batch store methods
1 parent 0bc26e5 commit 3cc190e

File tree

2 files changed

+134
-10
lines changed

2 files changed

+134
-10
lines changed

__tests__/batching.test.jsx

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import React, { Component } from 'react';
2-
import { render, cleanup, act } from '@testing-library/react/pure';
2+
import {
3+
render,
4+
cleanup,
5+
fireEvent,
6+
act,
7+
} from '@testing-library/react/pure';
38
import {
49
view,
510
store,
@@ -187,7 +192,35 @@ describe('batching', () => {
187192
expect(renderCount).toBe(2);
188193
});
189194

190-
test('should batch state changes inside native event listeners', () => {
195+
test('should batch state changes in react event listeners', () => {
196+
let renderCount = 0;
197+
const counter = store({ num: 0 });
198+
const MyComp = view(() => {
199+
renderCount += 1;
200+
return (
201+
<button
202+
type="button"
203+
onClick={() => {
204+
counter.num += 1;
205+
counter.num += 1;
206+
}}
207+
>
208+
{counter.num}
209+
</button>
210+
);
211+
});
212+
213+
const { container } = render(<MyComp />);
214+
const button = container.querySelector('button');
215+
expect(renderCount).toBe(1);
216+
expect(container).toHaveTextContent('0');
217+
fireEvent.click(button);
218+
expect(container).toHaveTextContent('2');
219+
expect(renderCount).toBe(2);
220+
});
221+
222+
// TODO: batching native event handlers causes in input caret jumping bug
223+
test.skip('should batch state changes inside native event listeners', () => {
191224
let renderCount = 0;
192225
const person = store({ name: 'Bob' });
193226
const MyComp = view(() => {
@@ -198,15 +231,15 @@ describe('batching', () => {
198231
const { container } = render(<MyComp />);
199232
expect(renderCount).toBe(1);
200233
expect(container).toHaveTextContent('Bob');
201-
const batched = act(() => {
234+
const handler = act(() => {
202235
person.name = 'Ann';
203236
person.name = 'Rick';
204237
});
205-
document.body.addEventListener('click', batched);
238+
document.body.addEventListener('click', handler);
206239
document.body.dispatchEvent(new Event('click'));
207240
expect(container).toHaveTextContent('Rick');
208241
expect(renderCount).toBe(2);
209-
document.body.removeEventListener('click', batched);
242+
document.body.removeEventListener('click', handler);
210243
});
211244

212245
// async/await is only batched when it is transpiled to promises and/or generators
@@ -314,6 +347,69 @@ describe('batching', () => {
314347
expect(renderCount).toBe(2);
315348
});
316349

350+
test('should batch changes in store methods', () => {
351+
let numOfRuns = 0;
352+
let name = '';
353+
354+
const myStore = store({
355+
firstName: 'My',
356+
lastName: 'Store',
357+
setName(firstName, lastName) {
358+
this.firstName = firstName;
359+
this.lastName = lastName;
360+
},
361+
});
362+
363+
const effect = autoEffect(() => {
364+
name = `${myStore.firstName} ${myStore.lastName}`;
365+
numOfRuns += 1;
366+
});
367+
expect(name).toBe('My Store');
368+
expect(numOfRuns).toBe(1);
369+
370+
myStore.setName('Awesome', 'Stuff');
371+
expect(name).toBe('Awesome Stuff');
372+
expect(numOfRuns).toBe(2);
373+
374+
clearEffect(effect);
375+
});
376+
377+
test('should batch changes in store setters', () => {
378+
let numOfRuns = 0;
379+
let name = '';
380+
381+
const myStore = store({
382+
firstName: 'My',
383+
middleName: 'Little',
384+
lastName: 'Store',
385+
get name() {
386+
return `${this.firstName} ${this.middleName} ${this.lastName}`;
387+
},
388+
set name(newName) {
389+
const [firstName, middleName, lastName] = newName.split(' ');
390+
this.firstName = firstName;
391+
this.middleName = middleName;
392+
this.lastName = lastName;
393+
},
394+
});
395+
396+
const effect = autoEffect(() => {
397+
name = myStore.name;
398+
numOfRuns += 1;
399+
});
400+
expect(name).toBe('My Little Store');
401+
expect(numOfRuns).toBe(1);
402+
403+
myStore.name = 'Awesome JS Stuff';
404+
expect(name).toBe('Awesome JS Stuff');
405+
expect(myStore.name).toBe('Awesome JS Stuff');
406+
// the reactions runs two times once for the setter call
407+
// and once for all the mutations inside the setter (alltogether)
408+
expect(numOfRuns).toBe(3);
409+
410+
clearEffect(effect);
411+
});
412+
317413
test('should not break Promises', async () => {
318414
await Promise.resolve(12)
319415
.then(value => {

src/store.js

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,43 @@
11
import { useMemo } from 'react';
22
import { observable } from '@nx-js/observer-util';
33

4+
import { batch } from './scheduler';
45
import {
56
isInsideFunctionComponent,
67
isInsideClassComponentRender,
78
isInsideFunctionComponentWithoutHooks,
89
} from './view';
910

11+
function batchMethods(obj) {
12+
Object.getOwnPropertyNames(obj).forEach(key => {
13+
const { value, set } = Object.getOwnPropertyDescriptor(obj, key);
14+
15+
// batch store methods
16+
if (typeof value === 'function') {
17+
// use a Proxy instead of function wrapper to keep the method name
18+
obj[key] = new Proxy(value, {
19+
apply(target, thisArg, args) {
20+
return batch(target, thisArg, args);
21+
},
22+
});
23+
} else if (set) {
24+
// batch property setters
25+
Object.defineProperty(obj, key, {
26+
set(newValue) {
27+
return batch(set, obj, [newValue]);
28+
},
29+
});
30+
}
31+
});
32+
return obj;
33+
}
34+
35+
function createStore(obj) {
36+
return batchMethods(
37+
observable(typeof obj === 'function' ? obj() : obj),
38+
);
39+
}
40+
1041
export function store(obj) {
1142
// do not create new versions of the store on every render
1243
// if it is a local store in a function component
@@ -15,10 +46,7 @@ export function store(obj) {
1546
// useMemo is not a semantic guarantee
1647
// In the future, React may choose to “forget” some previously memoized values and recalculate them on next render
1748
// see this docs for more explanation: https://reactjs.org/docs/hooks-reference.html#usememo
18-
return useMemo(
19-
() => observable(typeof obj === 'function' ? obj() : obj),
20-
[],
21-
);
49+
return useMemo(() => createStore(obj), []);
2250
}
2351
if (isInsideFunctionComponentWithoutHooks) {
2452
throw new Error(
@@ -30,5 +58,5 @@ export function store(obj) {
3058
'You cannot use state inside a render of a class component. Please create your store outside of the render function.',
3159
);
3260
}
33-
return observable(typeof obj === 'function' ? obj() : obj);
61+
return createStore(obj);
3462
}

0 commit comments

Comments
 (0)