Skip to content

Commit c416eaa

Browse files
authored
Merge pull request #4 from MatthewZito/dev-ctrl
feat: add stop, start, toggle effect handlers
2 parents 2081e6e + 3c482de commit c416eaa

16 files changed

Lines changed: 977 additions & 509 deletions

README.md

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ Reactive effects with automatic dependency management, caching, and leak-free fi
1313
- [Install](#install)
1414
- [Supported Environments](#support)
1515
- [Documentation](#docs)
16-
16+
- [Creating an Effect](#docs_effect)
17+
- [Starting and Stopping an Effect](#docs_control)
18+
- [Deferred Effects](#docs_defer)
1719

1820
## <a name="install"></a> Installation
1921

@@ -39,9 +41,13 @@ import { resonant, effect } from 'resonant';
3941

4042
## <a name="docs"></a> Documentation
4143

42-
Inspired by React's `useEffect` and Vue's `watchEffect`, `resonant` is a compact utility library that mitigates the inherent burdens of managing observable data, including dependency tracking; caching and cache invalidation; and object dereferencing and finalization.
44+
Inspired by React's `useEffect` and Vue's `watchEffect`, `resonant` is a compact utility library that mitigates the overhead of managing observable data, such as dependency tracking; caching and cache invalidation; and object dereferencing and finalization.
45+
46+
In `resonant`, an effect is a computation that is automatically invoked any time its reactive state changes. An effect's reactive state is any state that is accessed inside the effect body (specifically, the function passed to the `effect` initializer). A deterministic heuristic follows that any data access that triggers getters will be visible to and therefore tracked by the effect.
4347

44-
In `resonant`, an effect is a computation that is automatically invoked any time its reactive state changes.
48+
This reactive state 'resonates', hence `resonant`.
49+
50+
### <a name="docs_effect"></a> Creating an Effect
4551

4652
To create an effect, you must first make the target object (the effect state) reactive with the `resonant` function:
4753

@@ -56,7 +62,7 @@ const plainObject = {
5662
const r = resonant(plainObject);
5763
```
5864

59-
Now, `r` is equipped with deep reactivity. All get / set operations will trigger any effects that happen to be observing the data.
65+
`r` is now equipped with deep reactivity. All getters / setters will trigger any effects that happen to be observing the data.
6066

6167
Let's create an effect:
6268

@@ -77,9 +83,9 @@ effect(() => {
7783
});
7884
```
7985

80-
The effect will be invoked immediately. Next, the effect is cached and tracks `r` as a reactive dependency. Any time `r.x` or `r.y` change, the effect will run.
86+
The effect will be invoked immediately. Next, the effect is cached and tracks `r` as a reactive dependency. Any time `r.x` or `r.y` is mutated, the effect will run.
8187

82-
This works with branching and nested conditionals; if the effect encounters new properties by way of conditional logic, it tracks them as dependencies.
88+
This paradigm works with branching and nested conditionals; if the effect encounters new properties by way of conditional logic, it tracks them as dependencies.
8389

8490
```ts
8591
const r = resonant({
@@ -106,20 +112,120 @@ r.x.y.k = 1;
106112
// the effect will see the second condition and begin tracking `r.x.y.z`
107113
```
108114

109-
`resonant` uses weak references; deleted properties to which there are no references will be finalized so they may be garbage collected, as will all of that property's dependencies and effects. Finally, to nullify a resonant object's reactivity, use the `revokes` store:
115+
Effect dependencies are tracked lazily; the effect only ever cares about resonant data that it can see.
116+
117+
`resonant` uses weak references; deleted properties to which there are no references will be finalized so they may be garbage collected, as will all of that property's dependencies and effects.
118+
119+
### <a name="docs_control"></a> Starting and Stopping an Effect
120+
121+
To control an effect, each effect initializer returns unique `stop`, `start`, and `toggle` handlers. These functions are used to pause, resume, or toggle the effect's active state.
122+
123+
Use `stop` to pause an effect. The effect will not run during this period. Stopping an effect flushes its dependency cache, so subsequent `start` or `toggle` calls are akin to creating the effect anew.
110124

111125
```ts
112-
import { resonant, effect, revokes } from 'resonant';
126+
import { resonant, effect } from 'resonant';
113127

114-
const r = resonant({...});
128+
const r = resonant({ x: 1 });
115129

116-
effect(() => {
117-
...
130+
let c = 0;
131+
const { stop } = effect(() => {
132+
c += r.x;
133+
});
134+
135+
// initial run - `c` == 1
136+
137+
r.x++;
138+
139+
// trigger - `c` == 3
140+
141+
stop();
142+
143+
r.x++;
144+
145+
// `c` == 3
146+
```
147+
148+
Use `start` to transition the effect to an active state. `start` is idempotent; if the effect is already active, invoking `start` will *not* immediately trigger the effect. Otherwise, `start` - like instantiating a new effect - will run the effect immediately.
149+
150+
```ts
151+
import { resonant, effect } from 'resonant';
152+
153+
const r = resonant({ x: 1 });
154+
let c = 0;
155+
156+
const { stop, start } = effect(() => {
157+
c += r.x;
158+
});
159+
// initial run - r.x == 1, c == 1
160+
161+
r.x++;
162+
// r.x == 2, c == 3
163+
164+
stop();
165+
166+
r.x++;
167+
// r.x == 3, c == 3
168+
169+
start();
170+
// initial run - r.x == 3, c == 6
171+
172+
r.x++;
173+
// r.x == 4, c == 7
174+
```
175+
176+
Use `toggle` to toggle the effect's active state. Toggle invokes the appropriate `start` or `stop` handler and returns a boolean indicating whether the effect's state is active.
177+
178+
```ts
179+
import { resonant, effect } from 'resonant';
180+
181+
const r = resonant({ x: 1 });
182+
let c = 0;
183+
let isActive = true;
184+
185+
const { toggle } = effect(() => {
186+
c += r.x;
118187
});
188+
// initial run - r.x == 1, c == 1
189+
190+
r.x++;
191+
// r.x == 2, c == 3
192+
193+
isActive = toggle();
194+
// isActive == false
195+
196+
r.x++;
197+
// r.x == 3, c == 3
198+
199+
isActive = toggle();
200+
// isActive == true
201+
// initial run - r.x == 3, c == 6
202+
203+
r.x++;
204+
// r.x == 4, c == 7
205+
```
206+
207+
### <a name="docs_defer"></a> Deferred Effects
208+
209+
Effects may be initialized lazily with the `lazy` option. Passing this optional flag to the `effect` initializer will initialize the effect in an inactive state. The effect will *not* run immediately; either the effect's `start` or `toggle` handler *must be invoked before the effect can trigger*.
210+
211+
```ts
212+
import { resonant, effect } from 'resonant';
213+
214+
const r = resonant({ x: 1 });
215+
let c = 0;
216+
217+
const { start } = effect(() => {
218+
c += r.x;
219+
}, { lazy: true });
220+
// no initial run - r.x == 1, c == 0
221+
222+
r.x++;
223+
// r.x == 2, c == 0
119224

120-
const revoke = revokes.get(r);
225+
start();
121226

122-
revoke();
227+
r.x++;
228+
// r.x == 3, c == 3
123229
```
124230

125-
Full documentation can be found [here](https://matthewzito.github.io/resonant/resonant.html)
231+
Full documentation and type signatures can be found [here](https://matthewzito.github.io/resonant/resonant.html)

__tests__/control.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { effect, resonant } from '../src';
2+
3+
describe('effect controls', () => {
4+
it('stops an active effect', () => {
5+
const mock = jest.fn();
6+
const r = resonant({ x: 1, y: 1 });
7+
8+
const { stop } = effect(() => {
9+
r.x;
10+
r.y;
11+
mock();
12+
});
13+
14+
r.x = 11;
15+
16+
expect(mock).toHaveBeenCalledTimes(2);
17+
18+
stop();
19+
20+
r.x = 22;
21+
r.y = 22;
22+
23+
expect(mock).toHaveBeenCalledTimes(2);
24+
});
25+
26+
it('starts an inactive effect', () => {
27+
const mock = jest.fn();
28+
const r = resonant({ x: 1, y: 1 });
29+
30+
const { stop, start } = effect(() => {
31+
r.x;
32+
r.y;
33+
mock();
34+
});
35+
36+
r.x = 11;
37+
r.y = 11;
38+
39+
expect(mock).toHaveBeenCalledTimes(3);
40+
41+
stop();
42+
43+
r.x = 22;
44+
r.y = 22;
45+
46+
expect(mock).toHaveBeenCalledTimes(3);
47+
48+
start();
49+
50+
expect(mock).toHaveBeenCalledTimes(4);
51+
52+
r.x = 33;
53+
r.y = 33;
54+
55+
expect(mock).toHaveBeenCalledTimes(6);
56+
});
57+
58+
it("toggles an effect's active state", () => {
59+
let isActive = true;
60+
61+
const mock = jest.fn();
62+
const r = resonant({ x: 1, y: 1 });
63+
64+
const { toggle } = effect(() => {
65+
r.x;
66+
r.y;
67+
mock();
68+
});
69+
70+
r.x = 11;
71+
r.y = 11;
72+
73+
expect(mock).toHaveBeenCalledTimes(3);
74+
75+
isActive = toggle();
76+
expect(isActive).toBe(false);
77+
78+
r.x = 22;
79+
r.y = 22;
80+
81+
expect(mock).toHaveBeenCalledTimes(3);
82+
83+
isActive = toggle();
84+
expect(isActive).toBe(true);
85+
86+
r.x = 33;
87+
r.y = 33;
88+
89+
expect(mock).toHaveBeenCalledTimes(6);
90+
});
91+
92+
it('start and stop are each idempotent', () => {
93+
const mock = jest.fn();
94+
const r = resonant({ x: 1 });
95+
96+
const { start, stop } = effect(() => {
97+
r.x;
98+
mock();
99+
});
100+
101+
expect(mock).toHaveBeenCalledTimes(1);
102+
103+
r.x++;
104+
expect(mock).toHaveBeenCalledTimes(2);
105+
106+
start();
107+
expect(mock).toHaveBeenCalledTimes(2);
108+
109+
r.x++;
110+
expect(mock).toHaveBeenCalledTimes(3);
111+
112+
stop();
113+
expect(mock).toHaveBeenCalledTimes(3);
114+
115+
r.x++;
116+
expect(mock).toHaveBeenCalledTimes(3);
117+
118+
stop();
119+
expect(mock).toHaveBeenCalledTimes(3);
120+
121+
r.x++;
122+
expect(mock).toHaveBeenCalledTimes(3);
123+
});
124+
125+
it('defers initial effect invocation to the `start` handler when passed opts.lazy', () => {
126+
const mock = jest.fn();
127+
const r = resonant({ x: 1, y: 1 });
128+
129+
const { start, toggle } = effect(
130+
() => {
131+
r.x;
132+
mock();
133+
},
134+
{ lazy: true }
135+
);
136+
137+
const update = () => {
138+
r.x++;
139+
};
140+
141+
const tests: [() => void, number][] = [
142+
// ON
143+
[start, 1],
144+
[update, 2],
145+
// OFF
146+
[toggle, 2],
147+
[update, 2],
148+
// ON
149+
[toggle, 3],
150+
[update, 4],
151+
// ON
152+
[start, 4],
153+
[update, 5]
154+
];
155+
156+
for (const [operation, invocations] of tests) {
157+
operation();
158+
expect(mock).toHaveBeenCalledTimes(invocations);
159+
}
160+
});
161+
});

__tests__/resonant.test.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { resonant, effect, revokes } from '../src';
1+
import { resonant, effect } from '../src';
22

33
import { forMocks } from './utils';
44

@@ -266,8 +266,10 @@ describe('resonant', () => {
266266
mocks[0]();
267267

268268
if (r.x.y.z === 12) {
269-
// eslint-disable-next-line no-console
270-
console.log('trigger');
269+
if (process.env.TEST_DEV) {
270+
// eslint-disable-next-line no-console
271+
console.log('trigger');
272+
}
271273
}
272274

273275
if (r.a) {
@@ -411,28 +413,6 @@ describe('resonant', () => {
411413
expect(mock2).toHaveBeenCalledTimes(1);
412414
});
413415

414-
it('revokes a resonant value', () => {
415-
const mock = jest.fn();
416-
const r = resonant({ x: 1, y: 1 });
417-
418-
effect(() => {
419-
r.x;
420-
mock();
421-
});
422-
423-
r.x = 11;
424-
425-
const revoke = revokes.get(r);
426-
427-
revoke?.();
428-
429-
expect(() => {
430-
r.x = 22;
431-
}).toThrow("Cannot perform 'set' on a proxy that has been revoked");
432-
433-
expect(mock).toHaveBeenCalledTimes(2);
434-
});
435-
436416
it('triggers when deleting reactive properties', () => {
437417
const mock = jest.fn();
438418
const r = resonant({
@@ -482,6 +462,4 @@ describe('resonant', () => {
482462

483463
expect(mock2).toHaveBeenCalledTimes(1);
484464
});
485-
486-
it.todo('skips initial effect invocation if passed opts.lazy');
487465
});

0 commit comments

Comments
 (0)