Skip to content

Commit a80f853

Browse files
committed
Feature: Core Reactivity Enhancements: Effect Dependency Tracking & Dynamic Functions #6967
1 parent eff9df6 commit a80f853

4 files changed

Lines changed: 201 additions & 44 deletions

File tree

src/core/Config.mjs

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,41 @@
1+
import EffectManager from './EffectManager.mjs';
12
import {isDescriptor} from './ConfigSymbols.mjs';
23

34
/**
4-
* @src/util/ClassSystem.mjs Neo.core.Config
5-
* @private
6-
* @internal
7-
*
85
* Represents an observable container for a config property.
96
* This class manages the value of a config, its subscribers, and custom behaviors
107
* like merge strategies and equality checks defined via a descriptor object.
118
*
129
* The primary purpose of this class is to enable fine-grained reactivity and
1310
* decoupled cross-instance state sharing within the Neo.mjs framework.
11+
* @class Neo.core.Config
12+
* @private
13+
* @internal
1414
*/
1515
class Config {
16-
/**
17-
* The internal value of the config property.
18-
* @private
19-
* @apps/portal/view/about/MemberContainer.mjs {any} #value
20-
*/
21-
#value;
22-
2316
/**
2417
* A Set to store callback functions that subscribe to changes in this config's value.
2518
* @private
2619
*/
27-
#subscribers = {};
28-
20+
#subscribers = {}
2921
/**
30-
* The strategy to use when merging new values into this config.
31-
* Defaults to 'replace'. Can be overridden via a descriptor.
22+
* The internal value of the config property.
23+
* @member #value
24+
* @private
3225
*/
33-
mergeStrategy = 'replace';
34-
26+
#value
3527
/**
3628
* The function used to compare new and old values for equality.
3729
* Defaults to `Neo.isEqual`. Can be overridden via a descriptor.
30+
* @member {Function} isEqual=Neo.isEqual
3831
*/
39-
isEqual = Neo.isEqual;
32+
isEqual = Neo.isEqual
33+
/**
34+
* The strategy to use when merging new values into this config.
35+
* Defaults to 'replace'. Can be overridden via a descriptor merge property.
36+
* @member {Function|String} mergeStrategy='replace'
37+
*/
38+
mergeStrategy = 'replace'
4039

4140
/**
4241
* Creates an instance of Config.
@@ -55,16 +54,19 @@ class Config {
5554
* @returns {any} The current value.
5655
*/
5756
get() {
58-
return this.#value;
57+
// Registers this Config instance as a dependency with the currently active Effect,
58+
// enabling automatic re-execution when this Config's value changes.
59+
EffectManager.getActiveEffect()?.addDependency(this);
60+
return this.#value
5961
}
6062

6163
/**
6264
* Initializes the `Config` instance using a descriptor object.
6365
* Extracts `mergeStrategy` and `isEqual` from the descriptor.
6466
* The internal `#value` is NOT set by this method.
65-
* @param {Object} descriptor - The descriptor object for the config.
66-
* @param {any} descriptor.value - The default value for the config (not set by this method).
67-
* @param {string} [descriptor.merge='deep'] - The merge strategy.
67+
* @param {Object} descriptor - The descriptor object for the config.
68+
* @param {any} descriptor.value - The default value for the config (not set by this method).
69+
* @param {string} [descriptor.merge='deep'] - The merge strategy.
6870
* @param {Function} [descriptor.isEqual=Neo.isEqual] - The equality comparison function.
6971
*/
7072
initDescriptor({isEqual, merge}) {
@@ -84,7 +86,7 @@ class Config {
8486
if (this.#subscribers.hasOwnProperty(id)) {
8587
const subscriberSet = this.#subscribers[id];
8688
for (const callback of subscriberSet) {
87-
callback(newValue, oldValue);
89+
callback(newValue, oldValue)
8890
}
8991
}
9092
}
@@ -157,8 +159,11 @@ class Config {
157159
delete me.#subscribers[id]
158160
}
159161
}
160-
};
162+
}
161163
}
162164
}
163165

166+
const ns = Neo.ns('Neo.core', true);
167+
ns.Config = Config;
168+
164169
export default Config;

src/core/Effect.mjs

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,52 @@ import IdGenerator from './IdGenerator.mjs';
44
/**
55
* Creates a reactive effect that automatically tracks its dependencies and re-runs when any of them change.
66
* This is a lightweight, plain JavaScript class for performance.
7+
* It serves as a core reactive primitive, enabling automatic and dynamic dependency tracking.
78
* @class Neo.core.Effect
89
*/
910
class Effect {
1011
/**
11-
* A Set containing the cleanup functions for all current subscriptions.
12-
* @member {Set|null}
12+
* A Map containing Config instances as keys and their cleanup functions as values.
13+
* @member {Map} dependencies=new Map()
1314
* @protected
1415
*/
15-
dependencies = null
16+
dependencies = new Map()
1617
/**
1718
* The function to execute.
18-
* @member {Function|null}
19+
* @member {Function|null} _fn=null
1920
*/
20-
fn = null
21+
_fn = null
2122
/**
2223
* The unique identifier for this effect instance.
2324
* @member {String|null}
2425
*/
25-
id = null
26+
id = IdGenerator.getId('neo-effect')
2627
/**
2728
* @member {Boolean}
2829
* @protected
2930
*/
3031
isDestroyed = false
3132

33+
/**
34+
* @member fn
35+
*/
36+
get fn() {
37+
return this._fn
38+
}
39+
set fn(value) {
40+
this._fn = value;
41+
// Assigning a new function to `fn` automatically triggers a re-run.
42+
// This ensures that the effect immediately re-evaluates its dependencies
43+
// based on the new function's logic, clearing old dependencies and establishing new ones.
44+
this.run()
45+
}
46+
3247
/**
3348
* @param {Object} config
3449
* @param {Function} config.fn The function to execute for the effect.
3550
*/
3651
constructor({fn}) {
37-
Object.assign(this, {
38-
dependencies: new Set(),
39-
fn,
40-
id : IdGenerator.getId('neo-effect')
41-
});
42-
43-
this.run()
52+
this.fn = fn
4453
}
4554

4655
/**
@@ -57,6 +66,8 @@ class Effect {
5766
/**
5867
* Executes the effect function, tracking its dependencies.
5968
* This is called automatically on creation and whenever a dependency changes.
69+
* The dynamic re-tracking ensures the effect always reflects its current dependencies,
70+
* even if the logic within `fn` changes conditionally.
6071
* @protected
6172
*/
6273
run() {
@@ -83,14 +94,17 @@ class Effect {
8394
* @protected
8495
*/
8596
addDependency(config) {
86-
const
87-
me = this,
88-
cleanup = config.subscribe({
89-
id: me.id,
90-
fn: me.run.bind(me)
91-
});
97+
const me = this;
9298

93-
me.dependencies.add(cleanup);
99+
// Only add if not already a dependency. Map uses strict equality (===) for object keys.
100+
if (!me.dependencies.has(config)) {
101+
const cleanup = config.subscribe({
102+
id: me.id,
103+
fn: me.run.bind(me)
104+
});
105+
106+
me.dependencies.set(config, cleanup)
107+
}
94108
}
95109
}
96110

test/siesta/siesta.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ project.configure({
1919
project.plan(
2020
'tests/ClassConfigsAndFields.mjs',
2121
'tests/ClassSystem.mjs',
22+
{
23+
group: 'core',
24+
items: [
25+
'tests/core/Effect.mjs'
26+
]
27+
},
2228
{
2329
group: 'config',
2430
items: [
@@ -31,6 +37,13 @@ project.plan(
3137
'tests/config/CircularDependencies.mjs'
3238
]
3339
},
40+
{
41+
group: 'state',
42+
items: [
43+
'tests/state/createHierarchicalDataProxy.mjs',
44+
'tests/state/Provider.mjs'
45+
]
46+
},
3447
'tests/CollectionBase.mjs',
3548
'tests/ManagerInstance.mjs',
3649
'tests/Rectangle.mjs',

test/siesta/tests/core/Effect.mjs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import Neo from '../../../../src/Neo.mjs';
2+
import * as core from '../../../../src/core/_export.mjs';
3+
import Effect from '../../../../src/core/Effect.mjs';
4+
import EffectManager from '../../../../src/core/EffectManager.mjs';
5+
import Config from '../../../../src/core/Config.mjs';
6+
7+
StartTest(t => {
8+
t.it('EffectManager should manage active effects', t => {
9+
const effect1 = new Effect({fn: () => {}});
10+
const effect2 = new Effect({fn: () => {}});
11+
12+
t.is(EffectManager.getActiveEffect(), null, 'No active effect initially');
13+
14+
EffectManager.push(effect1);
15+
t.is(EffectManager.getActiveEffect(), effect1, 'Effect1 is active');
16+
17+
EffectManager.push(effect2);
18+
t.is(EffectManager.getActiveEffect(), effect2, 'Effect2 is active');
19+
20+
EffectManager.pop();
21+
t.is(EffectManager.getActiveEffect(), effect1, 'Effect1 is active after pop');
22+
23+
EffectManager.pop();
24+
t.is(EffectManager.getActiveEffect(), null, 'No active effect after all pops');
25+
26+
effect1.destroy();
27+
effect2.destroy();
28+
});
29+
30+
t.it('Effect should run its function and track dependencies', t => {
31+
let runCount = 0;
32+
const configA = new Config(1);
33+
const configB = new Config(10);
34+
35+
// Identity check
36+
t.is(configA, configA, 'configA is strictly equal to itself');
37+
t.is(configB, configB, 'configB is strictly equal to itself');
38+
t.isNot(configA, configB, 'configA is not strictly equal to configB');
39+
40+
const effect = new Effect({
41+
fn: () => {
42+
runCount++;
43+
// Access configs to register them as dependencies
44+
const sum = configA.get() + configB.get();
45+
t.pass(`Effect ran. Sum: ${sum}`);
46+
}
47+
});
48+
49+
t.is(runCount, 1, 'Effect function ran once on creation');
50+
t.is(effect.dependencies.size, 2, 'Effect tracked 2 dependencies');
51+
52+
// Change a dependency, effect should re-run
53+
configA.set(2);
54+
t.is(runCount, 2, 'Effect function ran again after configA change');
55+
56+
// Change another dependency, effect should re-run
57+
configB.set(20);
58+
t.is(runCount, 3, 'Effect function ran again after configB change');
59+
60+
// Change a dependency to the same value, effect should not re-run (Config handles this)
61+
configA.set(2);
62+
t.is(runCount, 3, 'Effect function did not run after no-change configA update');
63+
64+
effect.destroy();
65+
t.is(effect.isDestroyed, true, 'Effect is destroyed');
66+
t.is(effect.dependencies.size, 0, 'Effect dependencies cleared after destroy');
67+
68+
// Changing config after effect is destroyed should not re-run effect
69+
configA.set(3);
70+
t.is(runCount, 3, 'Effect function did not run after configA change when destroyed');
71+
});
72+
73+
t.it('Effect should clean up old dependencies on re-run', t => {
74+
let runCount = 0;
75+
const configX = new Config('X');
76+
const configY = new Config('Y');
77+
78+
// Initial effect: depends on configX
79+
const effect = new Effect({
80+
fn: () => {
81+
runCount++;
82+
t.is(configX.get(), 'X', 'Effect ran (1st): configX value');
83+
}
84+
});
85+
86+
t.is(runCount, 1, 'Effect ran once initially');
87+
t.is(effect.dependencies.size, 1, 'Effect has 1 dependency (configX)');
88+
89+
// --- Transition to configY dependency ---
90+
// Reassign the effect's function to depend on configY
91+
effect.fn = () => {
92+
runCount++;
93+
94+
if (runCount === 2) {
95+
t.is(configY.get(), 'Y', 'Effect ran (2nd): configY value');
96+
}
97+
98+
if (runCount === 3) {
99+
t.is(configY.get(), 'Y_new', 'Effect ran (3rd): configY value');
100+
}
101+
102+
else if (runCount === 4) {
103+
t.is(configY.get(), 'Y_final', 'Effect ran (4th): configY value');
104+
}
105+
};
106+
107+
t.is(runCount, 2, 'Effect ran a second time after fn reassignment');
108+
109+
// Changing the config value will trigger a re-run.
110+
configY.set('Y_new');
111+
112+
t.is(runCount, 3, 'Effect ran a second time after fn reassignment');
113+
t.is(effect.dependencies.size, 1, 'Effect now has 1 dependency (configY)');
114+
115+
// Change configX: should NOT trigger the effect (old dependency cleaned up)
116+
configX.set('X_new');
117+
t.is(runCount, 3, 'Effect did not re-run after old dependency (configX) changed');
118+
119+
// Change configY: should trigger the effect (new dependency)
120+
configY.set('Y_final'); // This will trigger runCount to become 3
121+
t.is(runCount, 4, 'Effect re-ran after new dependency (configY) changed');
122+
123+
effect.destroy();
124+
});
125+
});

0 commit comments

Comments
 (0)