Skip to content

Commit 0955042

Browse files
committed
automatic transitions via _enter() return values
1 parent 881e9ec commit 0955042

File tree

2 files changed

+105
-9
lines changed

2 files changed

+105
-9
lines changed

index.js

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export default function (state, states = {}) {
1+
export default function (initialState, states = {}) {
22
/*
33
* Core Finite State Machine functionality
44
* - adheres to Svelte store contract (https://svelte.dev/docs#Store_contract)
@@ -8,6 +8,7 @@ export default function (state, states = {}) {
88
*/
99
const subscribers = new Set();
1010
let proxy;
11+
let state = null;
1112

1213
function subscribe(callback) {
1314
if (typeof callback !== 'function') {
@@ -18,12 +19,43 @@ export default function (state, states = {}) {
1819
return () => subscribers.delete(callback);
1920
}
2021

22+
/*
23+
* API change: subscribers are notified after _enter, not before, because eventless transitions
24+
* might mean we settle in a new state. We may well transit through several states during the
25+
* _enter() call.
26+
*
27+
* The logic here is intricate. The protocol is that there is always, internally, calls to
28+
* _exit() followed by _enter(), with the same to and from arguments. The initial _exit()
29+
* and the final _enter() will use the public event. All calls will have the original event
30+
* and args -- since we never know in advance whether an event is a final one. If _enter()
31+
* returns a new state, we then generate a new _exit() and _enter, moving from the previous
32+
* state to the new one, and repeat. If it does not, then that _enter is the final call.
33+
*/
2134
function transition(newState, event, args) {
22-
const metadata = { from: state, to: newState, event, args };
23-
dispatch('_exit', metadata);
24-
state = newState;
25-
subscribers.forEach((callback) => callback(state));
26-
dispatch('_enter', metadata);
35+
let metadata = { from: state, to: newState, event, args };
36+
let startState = state;
37+
38+
// Never exit the null state
39+
if (state !== null) {
40+
dispatch('_exit', metadata);
41+
}
42+
43+
while(true) {
44+
state = metadata.to;
45+
const nextState = dispatch('_enter', metadata);
46+
if (! nextState) {
47+
break;
48+
}
49+
50+
metadata = { from: metadata.to, to: nextState, event, args }
51+
dispatch('_exit', metadata);
52+
}
53+
54+
// If (and only if) the final state is not the same as the initial state, then we
55+
// inform the subscribers
56+
if (state !== startState) {
57+
subscribers.forEach((callback) => callback(state));
58+
}
2759
}
2860

2961
function dispatch(event, ...args) {
@@ -78,8 +110,10 @@ export default function (state, states = {}) {
78110
});
79111

80112
/*
81-
* `_enter` initial state and return the proxy object
113+
* `_enter` initial state and return the proxy object. Note that this may also
114+
* involve eventless transitions to other states. Note, interestingly, that
115+
* we are free to notify here, because there will never be subscribers.
82116
*/
83-
dispatch('_enter', { from: null, to: state, event: null, args: [] });
117+
transition(initialState, null, []);
84118
return proxy;
85119
}

test/units.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,13 @@ describe('a finite state machine', () => {
142142
assert.throws(machine.arrowFunction, TypeError);
143143
});
144144

145+
// API change here. The notification callback will be called after
146+
// _enter, not before
145147
it('should call lifecycle actions in proper sequence', () => {
146148
machine.toggle();
147149
assert.isTrue(states.off._enter.calledBefore(states.off._exit));
148150
assert.isTrue(states.off._exit.calledBefore(callback));
149-
assert.isTrue(callback.calledBefore(states.on._enter));
151+
assert.isTrue(states.on._enter.calledBefore(callback));
150152
});
151153

152154
it('should call _enter with appropirate metadata when fsm is created', () => {
@@ -250,4 +252,64 @@ describe('a finite state machine', () => {
250252
assert.equal('off', state);
251253
});
252254
});
255+
256+
257+
describe('automatic transitions', () => {
258+
let callback;
259+
let unsubscribe;
260+
261+
beforeEach(() => {
262+
callback = sinon.fake();
263+
unsubscribe = machine.subscribe(callback);
264+
callback.resetHistory();
265+
});
266+
267+
afterEach(() => {
268+
unsubscribe();
269+
});
270+
271+
it('should perform an automatic transition once', () => {
272+
const enterOn = sinon.fake.returns('off')
273+
sinon.replace(states.on, '_enter', enterOn);
274+
machine.toggle();
275+
276+
const expected = {
277+
from: 'off',
278+
to: 'on',
279+
event: 'toggle',
280+
args: []
281+
};
282+
283+
assert.equal(states.on._enter.callCount, 1);
284+
assert.calledWithExactly(states.on._enter, expected);
285+
assert.equal(states.off._enter.callCount, 2);
286+
287+
assert.notCalled(callback);
288+
});
289+
290+
it('should perform an automatic transition multiple times', () => {
291+
const enterOn = sinon.fake.returns('off')
292+
sinon.replace(states.on, '_enter', enterOn);
293+
294+
machine.toggle();
295+
machine.toggle();
296+
machine.toggle();
297+
machine.toggle();
298+
299+
assert.equal(states.on._enter.callCount, 4);
300+
301+
const expected = {
302+
from: 'off',
303+
to: 'on',
304+
event: 'toggle',
305+
args: []
306+
};
307+
308+
assert.equal(states.on._enter.callCount, 4);
309+
assert.calledWithExactly(states.on._enter, expected);
310+
assert.equal(states.off._enter.callCount, 5);
311+
312+
assert.notCalled(callback);
313+
})
314+
});
253315
});

0 commit comments

Comments
 (0)