Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ jobs:

# we recommend new addons test the current and previous LTS
# as well as latest stable release (bonus points to beta/canary)
- env: EMBER_TRY_SCENARIO=ember-lts-3.16
- env: EMBER_TRY_SCENARIO=ember-lts-3.20
# - env: EMBER_TRY_SCENARIO=ember-lts-3.16
- env: EMBER_TRY_SCENARIO=ember-3.23
- env: EMBER_TRY_SCENARIO=ember-release
- env: EMBER_TRY_SCENARIO=ember-beta
- env: EMBER_TRY_SCENARIO=ember-canary
Expand Down
6 changes: 6 additions & 0 deletions .vim/coc-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"cSpell.words": [
"Typestate",
"xstate"
]
}
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ global application state).
Compatibility
------------------------------------------------------------------------------

* Ember.js v3.16 or above
* Ember CLI v2.13 or above
* Ember.js v3.23 or above
* Ember CLI v3.16 or above
* Node.js v10 or above

For classic Ember.js-versions pre Ember Octane please use the `0.8.x`-version
Expand Down Expand Up @@ -54,11 +54,10 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';

import { matchesState, useMachine } from 'ember-statecharts';
import { matchesState, Statechart } from 'ember-statecharts';
import { Machine } from 'xstate';

// @use (https://github.com/emberjs/rfcs/pull/567) is still WIP
import { use } from 'ember-usable';
import { use } from 'ember-could-get-used-to-this';

function noop() {}

Expand Down Expand Up @@ -106,7 +105,7 @@ export default class QuickstartButton extends Component {
return this.args.onClick || noop;
}

@use statechart = useMachine(buttonMachine)
@use statechart = new Statechart(buttonMachine)
.withContext({
disabled: this.args.disabled
})
Expand Down
63 changes: 4 additions & 59 deletions addon/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import useMachine, {
ConfigurableMachineDefinition,
InterpreterUsable,
} from './usables/use-machine';
export { useMachine } from './usables/use-machine';
export { Interpreter } from './usables/interpreter';

import {
EventObject,
matchesState as xstateMatchesState,
StateSchema,
StateValue,
Typestate,
} from 'xstate';
import { matchesState as xstateMatchesState, StateValue } from 'xstate';

/* eslint-disable @typescript-eslint/no-explicit-any */
function matchesState(state: StateValue, statechartPropertyName = 'statechart'): any {
export function matchesState(state: StateValue, statechartPropertyName = 'statechart'): any {
return function () {
return {
get(this: any): boolean {
Expand All @@ -29,50 +21,3 @@ function matchesState(state: StateValue, statechartPropertyName = 'statechart'):
};
};
}

/**
* No-op typecast function that turns what TypeScript believes to be a
* ConfigurableMachineDefinition function into a InterpreterUsable.
*
* ```js
* import { useMachine, interpreterFor } from 'ember-statecharts';
*
* class Foo extends EmberObject {
* @use statechart = useMachine(...) {
* // ...
* }
*
* someMethod() {
* this.statechart.send('WAT'); // TypeError
* interpreterFor(this.statechart).send('WAT'); // ok!
* }
* }
* ```
*
* @param configurableMachineDefinition The ConfigurableMachineDefinition used
* to initialize the `useMachine`-usable via `@use`
*
* Note that this is purely a typecast function.
*/
function interpreterFor<
TContext,
TStateSchema extends StateSchema,
TEvent extends EventObject,
TTypestate extends Typestate<TContext> = { value: any; context: TContext }
>(
configurableMachineDefinition: ConfigurableMachineDefinition<
TContext,
TStateSchema,
TEvent,
TTypestate
>
): InterpreterUsable<TContext, TStateSchema, TEvent> {
return (configurableMachineDefinition as unknown) as InterpreterUsable<
TContext,
TStateSchema,
TEvent,
TTypestate
>;
}

export { useMachine, matchesState, interpreterFor };
182 changes: 182 additions & 0 deletions addon/usables/interpreter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { DEBUG } from '@glimmer/env';
import { tracked } from '@glimmer/tracking';
import { assert, warn } from '@ember/debug';
import { action } from '@ember/object';
import { cancel, later } from '@ember/runloop';

import { Resource } from 'ember-could-get-used-to-this';
import { interpret, StateNode } from 'xstate';

import type {
EventObject,
Interpreter as XStateInterpreter,
State,
StateMachine,
StateSchema,
Typestate,
} from 'xstate';
import type { StateListener } from 'xstate/lib/interpreter';

const CONFIG = Symbol('config');
const MACHINE = Symbol('machine');

const ERROR_CHART_MISSING = `A statechart was not passed`;

export const ARGS_STATE_CHANGE_WARNING =
'A change to passed `args` or a local state change triggered an update to a `useMachine`-usable. You can send a dedicated event to the machine or restart it so this is handled. This is done via the `.update`-hook of the `useMachine`-usable.';

export type UpdateFunction<
Context,
Schema extends StateSchema,
Event extends EventObject
> = (args: {
machine: StateMachine<Context, Schema, Event>;
context: Context;
send: XStateInterpreter<Context, Schema, Event>['send'];
restart: () => void;
}) => void;

export type Config<Context, Schema extends StateSchema, Event extends EventObject> = {
onTransition?: StateListener<Context, Event, Schema, Typestate<Context>>;
initialState?: Parameters<XStateInterpreter<Context, Schema, Event>['start']>[0];
update?: UpdateFunction<Context, Schema, Event>;
};

export type Args<Context, Schema extends StateSchema, Event extends EventObject> = {
named?: {
machine: StateNode<Context, Schema, Event>;
config: Config<Context, Schema, Event>;
};
};

type SendArgs<Context, Schema extends StateSchema, Event extends EventObject> = Parameters<
XStateInterpreter<Context, Schema, Event>['send']
>;

export class Interpreter<
Context,
Schema extends StateSchema,
Event extends EventObject
> extends Resource<Args<Context, Schema, Event>> {
declare [MACHINE]: StateMachine<Context, Schema, Event>;

private declare _interpreter?: XStateInterpreter<Context, Schema, Event>;

@tracked state?: State<Context, Event>;

/**
* This is the return value of `new Statechart(() => ...)`
*/
get value(): {
state?: State<Context, Event>;
send: Interpreter<Context, Schema, Event>['send'];
} {
if (!this._interpreter) {
this._setupMachine();
}

return {
// For TypeScript, this is tricky because this is what is accessible at the call site
// but typescript thinks the context is the class instance.
//
// To remedy, each property has to also exist on the class body under the same name
state: this.state,
send: this.send,
};
}

/**
* Private
*/

private get [CONFIG]() {
return this.args.named?.config;
}

@action
send(...args: SendArgs<Context, Schema, Event>) {
if (!this._interpreter) {
this._setupMachine();
}

assert(`Failed to set up interpreter`, this._interpreter);

return this._interpreter.send(...args);
}

@action
private _setupMachine() {
this._interpreter = interpret(this[MACHINE], {
devTools: DEBUG,
clock: {
setTimeout(fn, ms) {
return later.call(null, fn, ms);
},
clearTimeout(timer) {
return cancel.call(null, timer);
},
},
}).onTransition((state) => {
this.state = state;
});

this.onTransition(this[CONFIG]?.onTransition);

this._interpreter.start(this[CONFIG]?.initialState);
}

@action
private onTransition(fn?: StateListener<Context, Event, Schema, Typestate<Context>>) {
if (!this._interpreter) {
this._setupMachine();
}

assert(`Failed to set up interpreter`, this._interpreter);

if (fn) {
this._interpreter.onTransition(fn);
}

return this;
}

/**
* Lifecycle methods on Resource
*
*/
protected setup() {
const machine = this.args.named?.machine;

assert(ERROR_CHART_MISSING, machine);

this[MACHINE] = machine;
}

protected update() {
const updateCallback = this[CONFIG]?.update;

if (updateCallback) {
assert(`Expected interpreter to have been set up`, this._interpreter);

updateCallback({
machine: this[MACHINE],
context: this._interpreter.state.context,
send: this._interpreter.send,
restart: () => {
this.teardown();
this.setup();
},
});
} else {
warn(ARGS_STATE_CHANGE_WARNING, false, { id: 'statecharts.use-machine.args-state-change' });
}
}

protected teardown() {
if (!this._interpreter) return;

this._interpreter.stop();

this._interpreter = undefined;
}
}
Loading