Skip to content

Commit 8cbebf3

Browse files
committed
Implement Synchronous Batching for Effect Executions #6968
1 parent 2bf8804 commit 8cbebf3

7 files changed

Lines changed: 439 additions & 24 deletions

File tree

src/Neo.mjs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -824,8 +824,13 @@ function autoGenerateGetSet(proto, key) {
824824
const config = this.getConfig(key);
825825
if (!config) return;
826826

827-
let me = this,
828-
oldValue = config.get(); // Get the old value from the Config instance
827+
let me = this,
828+
oldValue = config.get(), // Get the old value from the Config instance
829+
{EffectBatchManager} = Neo.core,
830+
isNewBatch = !EffectBatchManager?.isBatchActive();
831+
832+
// If a config change is not triggered via `core.Base#set()`, honor changes inside hooks.
833+
isNewBatch && EffectBatchManager?.startBatch();
829834

830835
// 1. Prevent infinite loops:
831836
// Immediately remove the pending value from the configSymbol to prevent a getter from
@@ -845,7 +850,12 @@ function autoGenerateGetSet(proto, key) {
845850
value = me[beforeSet](value, oldValue);
846851

847852
// If they don't return a value, that means no change
848-
if (value === undefined) return;
853+
if (value === undefined) {
854+
// Restore the original value if the update is canceled.
855+
me[_key] = oldValue;
856+
isNewBatch && EffectBatchManager?.endBatch();
857+
return
858+
}
849859
}
850860

851861
// 3. Restore state for change detection:
@@ -860,6 +870,8 @@ function autoGenerateGetSet(proto, key) {
860870
me[afterSet]?.(value, oldValue);
861871
me.afterSetConfig?.(key, value, oldValue)
862872
}
873+
874+
isNewBatch && EffectBatchManager?.endBatch()
863875
}
864876
};
865877

src/core/Base.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import Compare from '../core/Co
33
import Util from '../core/Util.mjs';
44
import Config from './Config.mjs';
55
import {isDescriptor} from './ConfigSymbols.mjs';
6-
import IdGenerator from './IdGenerator.mjs'
6+
import IdGenerator from './IdGenerator.mjs';
7+
import EffectBatchManager from './EffectBatchManager.mjs';
78

89
const configSymbol = Symbol.for('configSymbol'),
910
forceAssignConfigs = Symbol('forceAssignConfigs'),
@@ -764,6 +765,9 @@ class Base {
764765
let me = this,
765766
classFieldsViaSet = {};
766767

768+
// Start batching for effects
769+
EffectBatchManager.startBatch();
770+
767771
values = me.setFields(values);
768772

769773
// If the initial config processing is still running,
@@ -791,7 +795,10 @@ class Base {
791795
})
792796

793797
// Process reactive configs
794-
me.processConfigs(true)
798+
me.processConfigs(true);
799+
800+
// End batching for effects
801+
EffectBatchManager.endBatch();
795802
}
796803

797804
/**

src/core/Effect.mjs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import EffectManager from './EffectManager.mjs';
2-
import IdGenerator from './IdGenerator.mjs';
1+
import EffectManager from './EffectManager.mjs';
2+
import EffectBatchManager from './EffectBatchManager.mjs';
3+
import IdGenerator from './IdGenerator.mjs';
34

45
/**
56
* Creates a reactive effect that automatically tracks its dependencies and re-runs when any of them change.
@@ -75,7 +76,11 @@ class Effect {
7576

7677
if (me.isDestroyed) return;
7778

78-
// Clean up old dependencies before re-running to avoid stale subscriptions.
79+
if (EffectBatchManager.isBatchActive()) {
80+
EffectBatchManager.queueEffect(me);
81+
return
82+
}
83+
7984
me.dependencies.forEach(cleanup => cleanup());
8085
me.dependencies.clear();
8186

src/core/EffectBatchManager.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* A singleton manager responsible for batching `Neo.core.Effect` executions.
3+
* This ensures that effects triggered by multiple config changes within a single
4+
* synchronous operation (e.g., `Neo.core.Base#set()`) are executed only once
5+
* per batch, after all changes have been applied.
6+
* @class Neo.core.EffectBatchManager
7+
* @singleton
8+
*/
9+
const EffectBatchManager = {
10+
/**
11+
* The current count of active batch operations.
12+
* Incremented by `startBatch()`, decremented by `endBatch()`.
13+
* @member {Number} batchCount=0
14+
*/
15+
batchCount: 0,
16+
/**
17+
* A Set of `Neo.core.Effect` instances that are pending execution within the current batch.
18+
* @member {Set<Neo.core.Effect>} pendingEffects=new Set()
19+
*/
20+
pendingEffects: new Set(),
21+
22+
/**
23+
* Increments the batch counter. When `batchCount` is greater than 0,
24+
* effects will be queued instead of running immediately.
25+
*/
26+
startBatch() {
27+
this.batchCount++
28+
},
29+
30+
/**
31+
* Decrements the batch counter. If `batchCount` reaches 0, all queued effects
32+
* are executed and the `pendingEffects` Set is cleared.
33+
*/
34+
endBatch() {
35+
this.batchCount--;
36+
37+
if (this.batchCount === 0) {
38+
this.pendingEffects.forEach(effect => {
39+
effect.run();
40+
});
41+
42+
this.pendingEffects.clear()
43+
}
44+
},
45+
46+
/**
47+
* Checks if there is an active batch operation.
48+
* @returns {Boolean}
49+
*/
50+
isBatchActive() {
51+
return this.batchCount > 0
52+
},
53+
54+
/**
55+
* Queues an effect for execution at the end of the current batch.
56+
* If the effect is already queued, it will not be added again.
57+
* @param {Neo.core.Effect} effect The effect to queue.
58+
*/
59+
queueEffect(effect) {
60+
this.pendingEffects.add(effect)
61+
}
62+
};
63+
64+
// Assign to Neo namespace
65+
const ns = Neo.ns('Neo.core', true);
66+
ns.EffectBatchManager = EffectBatchManager;
67+
68+
export default EffectBatchManager;

test/siesta/siesta.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ project.plan(
3434
'tests/config/CustomFunctions.mjs',
3535
'tests/config/AfterSetConfig.mjs',
3636
'tests/config/MemoryLeak.mjs',
37-
'tests/config/CircularDependencies.mjs'
37+
'tests/config/CircularDependencies.mjs',
38+
'tests/core/EffectBatching.mjs'
3839
]
3940
},
4041
{

test/siesta/tests/core/Effect.mjs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,33 +47,33 @@ StartTest(t => {
4747
}
4848
});
4949

50-
t.is(runCount, 1, 'Effect function ran once on creation');
51-
t.is(effect.dependencies.size, 2, 'Effect tracked 2 dependencies');
52-
t.is(sum, 11, 'Effect function ran with correct sum: 1 + 10 = 11');
50+
t.is(runCount, 1, 'Effect function ran once on creation');
51+
t.is(effect.dependencies.size, 2, 'Effect tracked 2 dependencies');
52+
t.is(sum, 11, 'Effect function ran with correct sum: 1 + 10 = 11');
5353

5454
// Change a dependency, effect should re-run
5555
configA.set(2);
56-
t.is(runCount, 2, 'Effect function ran again after configA change');
57-
t.is(sum, 12, 'Effect function ran with correct sum: 2 + 10 = 12');
56+
t.is(runCount, 2, 'Effect function ran again after configA change');
57+
t.is(sum, 12, 'Effect function ran with correct sum: 2 + 10 = 12');
5858

5959
// Change another dependency, effect should re-run
6060
configB.set(20);
61-
t.is(runCount, 3, 'Effect function ran again after configB change');
62-
t.is(sum, 22, 'Effect function ran with correct sum: 2 + 20 = 22');
61+
t.is(runCount, 3, 'Effect function ran again after configB change');
62+
t.is(sum, 22, 'Effect function ran with correct sum: 2 + 20 = 22');
6363

6464
// Change a dependency to the same value, effect should not re-run (Config handles this)
6565
configA.set(2);
66-
t.is(runCount, 3, 'Effect function did not run after no-change configA update');
67-
t.is(sum, 22, 'Effect function ran with correct sum: 2 + 20 = 22');
66+
t.is(runCount, 3, 'Effect function did not run after no-change configA update');
67+
t.is(sum, 22, 'Effect function ran with correct sum: 2 + 20 = 22');
6868

6969
effect.destroy();
70-
t.is(effect.isDestroyed, true, 'Effect is destroyed');
71-
t.is(effect.dependencies.size, 0, 'Effect dependencies cleared after destroy');
70+
t.is(effect.isDestroyed, true, 'Effect is destroyed');
71+
t.is(effect.dependencies.size, 0, 'Effect dependencies cleared after destroy');
7272

7373
// Changing config after effect is destroyed should not re-run effect
7474
configA.set(3);
75-
t.is(runCount, 3, 'Effect function did not run after configA change when destroyed');
76-
t.is(sum, 22, 'Effect function ran with correct sum: 2 + 20 = 22');
75+
t.is(runCount, 3, 'Effect function did not run after configA change when destroyed');
76+
t.is(sum, 22, 'Effect function ran with correct sum: 2 + 20 = 22');
7777
});
7878

7979
t.it('Effect should clean up old dependencies on re-run', t => {
@@ -89,7 +89,7 @@ StartTest(t => {
8989
}
9090
});
9191

92-
t.is(runCount, 1, 'Effect ran once initially');
92+
t.is(runCount, 1, 'Effect ran once initially');
9393
t.is(effect.dependencies.size, 1, 'Effect has 1 dependency (configX)');
9494

9595
// --- Transition to configY dependency ---
@@ -115,7 +115,7 @@ StartTest(t => {
115115
// Changing the config value will trigger a re-run.
116116
configY.set('Y_new');
117117

118-
t.is(runCount, 3, 'Effect ran a second time after fn reassignment');
118+
t.is(runCount, 3, 'Effect ran a second time after fn reassignment');
119119
t.is(effect.dependencies.size, 1, 'Effect now has 1 dependency (configY)');
120120

121121
// Change configX: should NOT trigger the effect (old dependency cleaned up)

0 commit comments

Comments
 (0)