diff --git a/packages/overmind-statechart/src/index.test.ts b/packages/overmind-statechart/src/index.test.ts index a5f258d5..c5ac04e3 100644 --- a/packages/overmind-statechart/src/index.test.ts +++ b/packages/overmind-statechart/src/index.test.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import { IContext, createOvermind } from 'overmind' +import { IContext, createOvermind, pipe, wait } from 'overmind' import { Statechart, statechart } from './' @@ -1081,4 +1081,313 @@ describe('Statecharts', () => { 'step2', ]) }) + + test('should work with operator-based action using pipe', async () => { + const increaseCount = pipe(function step({ state }: any) { + state.count++ + }) + + const state = { + count: 0, + } + const actions = { + increaseCount, + } + + const config = { + state, + actions, + } + + const chart: Statechart< + typeof config, + { + foo: void + bar: void + } + > = { + initial: 'foo', + states: { + foo: { + on: { + increaseCount: 'bar', + }, + }, + bar: {}, + }, + } + + const instance = createOvermind( + statechart(config, { + id1: chart, + }) + ) + + expect(instance.state.states).toEqual([['id1', 'foo']]) + expect(instance.state.actions).toEqual({ increaseCount: true }) + + await instance.actions.increaseCount() + + expect(instance.state.states).toEqual([['id1', 'bar']]) + expect(instance.state.actions).toEqual({ increaseCount: false }) + expect(instance.state.count).toBe(1) + }) + + test('should work with multi-step operator pipe action', async () => { + const doTransition = pipe( + function addFirst({ state }: any) { + state.steps.push('first') + }, + function addSecond({ state }: any) { + state.steps.push('second') + } + ) + + const state = { + steps: [] as string[], + } + const actions = { + doTransition, + } + + const config = { + state, + actions, + } + + const chart: Statechart< + typeof config, + { + foo: void + bar: void + } + > = { + initial: 'foo', + states: { + foo: { + on: { + doTransition: 'bar', + }, + }, + bar: {}, + }, + } + + const instance = createOvermind( + statechart(config, { + id1: chart, + }) + ) + + await instance.actions.doTransition() + + expect(instance.state.states).toEqual([['id1', 'bar']]) + expect(instance.state.steps).toEqual(['first', 'second']) + }) + + test('should await operator-based exit action with async steps before main action', async () => { + const exitAction = pipe(wait(50), function onExit({ state }: any) { + state.events.push('exit') + }) + + const doTransition = pipe(function step({ state }: any) { + state.events.push('main') + }) + + const state = { + events: [] as string[], + } + const actions = { + exitAction, + doTransition, + } + + const config = { + state, + actions, + } + + const chart: Statechart< + typeof config, + { + foo: void + bar: void + } + > = { + initial: 'foo', + states: { + foo: { + exit: 'exitAction', + on: { + doTransition: 'bar', + }, + }, + bar: {}, + }, + } + + const instance = createOvermind( + statechart(config, { + id1: chart, + }) + ) + + await instance.actions.doTransition() + + expect(instance.state.states).toEqual([['id1', 'bar']]) + expect(instance.state.events).toEqual(['exit', 'main']) + }) + + test('should block operator-based action not allowed by statechart', async () => { + const increaseCount = pipe(function step({ state }: any) { + state.count++ + }) + + const state = { + count: 0, + } + const actions = { + increaseCount, + } + + const config = { + state, + actions, + } + + const chart: Statechart< + typeof config, + { + foo: void + bar: void + } + > = { + initial: 'foo', + states: { + foo: {}, + bar: { + on: { + increaseCount: null, + }, + }, + }, + } + + const instance = createOvermind( + statechart(config, { + id1: chart, + }) + ) + + expect(instance.state.actions).toEqual({ increaseCount: false }) + + await instance.actions.increaseCount() + + expect(instance.state.count).toBe(0) + }) + + test('should work with operator-based action without transition target', async () => { + const increaseCount = pipe(function step({ state }: any) { + state.count++ + }) + + const state = { + count: 0, + } + const actions = { + increaseCount, + } + + const config = { + state, + actions, + } + + const chart: Statechart< + typeof config, + { + foo: void + } + > = { + initial: 'foo', + states: { + foo: { + on: { + increaseCount: null, + }, + }, + }, + } + + const instance = createOvermind( + statechart(config, { + id1: chart, + }) + ) + + expect(instance.state.actions).toEqual({ increaseCount: true }) + + await instance.actions.increaseCount() + + expect(instance.state.states).toEqual([['id1', 'foo']]) + expect(instance.state.count).toBe(1) + }) + + test('should complete async exit action then execute transition', async () => { + // Verifies that async exit actions fully complete before the + // main action and transition execute + const exitAction = async ({ state }: any) => { + state.events.push('exit-start') + await new Promise((resolve) => setTimeout(resolve, 10)) + state.events.push('exit-end') + } + + const doTransition = ({ state }: any) => { + state.events.push('main') + } + + const state = { + events: [] as string[], + } + const actions = { + exitAction, + doTransition, + } + + const config = { + state, + actions, + } + + const chart: Statechart< + typeof config, + { + foo: void + bar: void + } + > = { + initial: 'foo', + states: { + foo: { + exit: 'exitAction', + on: { + doTransition: 'bar', + }, + }, + bar: {}, + }, + } + + const instance = createOvermind( + statechart(config, { + id1: chart, + }) + ) + + await instance.actions.doTransition() + + expect(instance.state.states).toEqual([['id1', 'bar']]) + // Exit must fully complete (both start and end) before main action + expect(instance.state.events).toEqual(['exit-start', 'exit-end', 'main']) + }) }) diff --git a/packages/overmind-statechart/src/index.ts b/packages/overmind-statechart/src/index.ts index fc7c3477..97985965 100644 --- a/packages/overmind-statechart/src/index.ts +++ b/packages/overmind-statechart/src/index.ts @@ -1,4 +1,11 @@ -import { ENVIRONMENT, IConfiguration, derived, filter, pipe } from 'overmind' +import { + ENVIRONMENT, + IConfiguration, + derived, + filter, + isPromise, + pipe, +} from 'overmind' const ACTIONS = 'ACTIONS' const CHART = 'CHART' @@ -509,52 +516,73 @@ export function statechart< } }) - // Run exits - exitActions.forEach((exitAction) => { + function executeTransition() { + currentTransitionAction = key + let actionResult if (config.actions) { - actionsTarget[ACTIONS][exitAction](payload) + actionResult = actionsTarget[ACTIONS][key](payload) } - }) - currentTransitionAction = key - let actionResult - if (config.actions) { - actionResult = actionsTarget[ACTIONS][key](payload) - } + currentTransitionAction = null - currentTransitionAction = null + // Transition to new state + stateTarget.states = newStates - // Transition to new state - stateTarget.states = newStates + // Run entry actions + entryActions.forEach((entryAction) => { + if (config.actions) { + actionsTarget[ACTIONS][entryAction](payload) + } + }) - // Run entry actions - entryActions.forEach((entryAction) => { + if ( + ENVIRONMENT === 'development' && + currentInstance && + currentInstance.devtools + ) { + currentInstance.devtools.send({ + type: 'chart', + data: { + path: context.execution.namespacePath, + states: stateTarget.states, + charts, + actions: getCanTransitionActions( + copiedActions, + charts, + stateTarget + ), + }, + }) + } + + return actionResult + } + + // Run exits, awaiting any that return promises + const exitResults: any[] = [] + exitActions.forEach((exitAction) => { if (config.actions) { - actionsTarget[ACTIONS][entryAction](payload) + exitResults.push(actionsTarget[ACTIONS][exitAction](payload)) } }) - if ( - ENVIRONMENT === 'development' && - currentInstance && - currentInstance.devtools - ) { - currentInstance.devtools.send({ - type: 'chart', - data: { - path: context.execution.namespacePath, - states: stateTarget.states, - charts, - actions: getCanTransitionActions( - config.actions, - charts, - stateTarget - ), - }, + const pendingExits = exitResults.filter(isPromise) + + if (pendingExits.length) { + return Promise.all(pendingExits).then(() => { + // Re-validate that the transition is still valid after + // async exit actions complete, since state may have + // changed during the async gap + const canStillTransition = stateTarget.actions[key] + if (!canStillTransition) { + return + } + + return executeTransition() }) } - return actionResult + return executeTransition() } ) diff --git a/packages/overmind/src/index.ts b/packages/overmind/src/index.ts index 613a54ed..30b6db94 100644 --- a/packages/overmind/src/index.ts +++ b/packages/overmind/src/index.ts @@ -13,7 +13,14 @@ export type { ContextFunction, } from './internalTypes' export { createOperator, createMutationOperator } from './operator' -export { MODE_DEFAULT, MODE_TEST, MODE_SSR, ENVIRONMENT, json } from './utils' +export { + MODE_DEFAULT, + MODE_TEST, + MODE_SSR, + ENVIRONMENT, + json, + isPromise, +} from './utils' export { SERIALIZE, rehydrate } from './rehydrate' export { type MachineMethods, diff --git a/packages/overmind/src/operators.test.ts b/packages/overmind/src/operators.test.ts index 86a0807e..fe33c4b7 100644 --- a/packages/overmind/src/operators.test.ts +++ b/packages/overmind/src/operators.test.ts @@ -363,6 +363,42 @@ describe('OPERATORS', () => { ) }) + test('throttle passes most recent value', () => { + expect.assertions(2) + const test = pipe(throttle(0), ({ state }: Context, value: number) => { + state.lastValue = value + state.runCount++ + }) + const state = { + lastValue: -1, + runCount: 0, + } + const actions = { + test, + } + const config = { + state, + actions, + } + const overmind = new Overmind(config) + + type Context = IContext<{ + state: typeof state + actions: { + test: typeof actions.test + } + }> + + return Promise.all([ + overmind.actions.test(1), + overmind.actions.test(2), + overmind.actions.test(3), + ]).then(() => { + expect(overmind.state.runCount).toBe(1) + expect(overmind.state.lastValue).toBe(3) + }) + }) + test('catchError', () => { expect.assertions(3) const test = pipe( diff --git a/packages/overmind/src/operators.ts b/packages/overmind/src/operators.ts index 583e3e3b..4391fbfa 100644 --- a/packages/overmind/src/operators.ts +++ b/packages/overmind/src/operators.ts @@ -535,6 +535,7 @@ export function throttle(ms: number): IOperator { let timeout: any let previousFinal: any let currentNext: any + let currentValue: any return createOperator( 'throttle', @@ -548,11 +549,12 @@ export function throttle(ms: number): IOperator { } else { timeout = setTimeout(() => { timeout = null - currentNext(null, value) + currentNext(null, currentValue) }, ms) } previousFinal = final currentNext = next + currentValue = value } } )