Skip to content

Instantly share code, notes, and snippets.

@balazsbajorics
Last active November 14, 2019 18:36
Show Gist options
  • Save balazsbajorics/8a3c88cc8ea7ec9d1845b66282eb1c9c to your computer and use it in GitHub Desktop.
Save balazsbajorics/8a3c88cc8ea7ec9d1845b66282eb1c9c to your computer and use it in GitHub Desktop.
Generated by XState Viz: https://xstate.js.org/viz
const bundlerMachine = Machine({
id: 'bundlermachine',
type: 'parallel',
// the initial context. very similar to a redux-like state object.
// the context object is immutable, and you must use Actions (side effects) to change its value
// the Action to change the context's value is called `assign`. dont ask me why.
context: {
queuedUpdateFiles: {},
weWantToEmit: false,
fileQueuedForProcessing: null,
},
// the top level state machine has two sub-machines: worker, and queue.
states: {
// the worker state machine is responsible for communicating with the worker via Promises
worker: {
initial: 'uninitialized',
states: {
// it starts in an unitialized state, the only valid message it can respond to is INITIALIZE
uninitialized: {
// so what is happening here? `on` is an object that is in the shape of
// `{[eventName: string]: TransitionObject}
// INITIALIZE is the event we are listening to, and as a
// response we transition to a new state called `initializing`
// we could add configuration here, fire Actions (side effects)
// and optionally we can omit `target`, which means we do not switch state, just fire side effects for example.
on: {
INITIALIZE: {
target: 'initializing',
},
},
},
// This state invokes the initializeWorkerPromise promise. (passed in as configuration)
// when the promise resolves, the onDone handler will be executed
initializing: {
invoke: {
id: 'initializeBundler',
// this here is a string key, we expect the same string to be in the config object.
// instead of a string key, this could be a function call that returns a Promise, for faster prototyping
src: 'initializeWorkerPromise',
// when the promise resolves, the onDone event is fired.
onDone: {
// the transition object, describing we want to go back to the 'idle' state,
// and as a side effect console.log('INITIALIZEd!')
target: 'idle',
actions: [actions.log(() => 'INITIALIZEd!')],
},
},
},
idle: {
// when we enter the idle state, fire a POP event, just in case the queue is not empty
entry: [send('POP')],
on: {
// when a PUSH event enters the system, put a POP event to the back of the State Machine's event queue.
PUSH: {
// the POP event will make our queue state machine to fire a PROCESS_FILE_FROM_QUEUE event
actions: [send('POP')],
},
// the PROCESS_FILE_FROM_QUEUE event means the queue was not empty,
// and now there is a file ready to be processed, let's switch states!
PROCESS_FILE_FROM_QUEUE: {
target: 'processing',
},
},
},
processing: {
invoke: {
id: 'sendUpdateFileMessage',
// let's call the updateFileWorkerPromise, and wait until it resolves
src: 'updateFileWorkerPromise',
onDone: {
// success! the promise resolved, we can go back to idle
target: 'idle',
// but before we go,...
actions: [
assign({
// let's delete the property named fileQueuedForProcessing from context
fileQueuedForProcessing: (context, event) => {
// instead of this callback function, I could just write `null` here, except typescript complained
return null
},
}),
// and one more logging side effect!
actions.log((context, event) => {
return 'UPDATE BUNDLE SUCCESSFUL'
}),
],
},
},
},
},
},
// queue is a state machine implementation of Rheese's queueing code
queue: {
initial: 'empty',
states: {
// to be honest, I'm not sure we need an empty AND a ready state too,
// but at least this way, it is very explicit that you can not POP an empty queue,
empty: {
on: {
PUSH: {
target: 'pushing',
},
},
},
// this is the non-empty idle state of the queue state machine
ready: {
on: {
PUSH: {
target: 'pushing',
},
POP: {
target: 'popping',
},
},
},
pushing: {
// entry describes a list of actions (side effects) to be executed when we step into this state
// it has a dual, called `exit`
entry: [
// assign is like React.setState for the `context` object. here we describe that we want to set
// the value of queuedUpdateFiles to include event.payload.fileContent, and
// weWantToEmit to be event.payload.weWantToEmit
assign({
queuedUpdateFiles: (context, event) => {
const updatedQueue = {
...context.queuedUpdateFiles,
[event.payload.fileName]: event.payload.fileContent,
}
return updatedQueue
},
// btw do you see that there's a lot of `(context, event) => {}` callbacks? it is because
// every action, no matter where we are executing receives the same params.
// `context` is the latest context of the entire state machine
// `event` is the event that the state machine is currently acting on.
// notice how we were in the empty/ready state when a PUSH event was fired,
// event here is that push event, with its payload.
// I was too lazy to type the events up, but xstate is pretty typescript-friendly, so don't worry friends!
weWantToEmit: (context, event) => event.payload.weWantToEmit,
}),
],
on: {
// the `''` event is a special event, that is fired when we step into this state. since I am
// describing a transition with a target as the handler,
// it basically means "as soon as we step into the state called `pushing`, move to a new state"
'': [
// everywhere else in the system, I just have a single object as the Transition, but here we have an
// array of two objects! What's up with that? the answer is `cond`...
{
target: 'empty',
// `cond` is a Guard. a guard is a function that receives the usual `(context, event)` parameters
// and must return true if we allow the state machine to transition to the state described in the object
// if it returns false, the evaluator will move on to the next Transition objet in the array
cond: (context) => Object.keys(context.queuedUpdateFiles).length === 0,
},
{
target: 'ready',
},
],
},
},
popping: {
entry: [
assign((context) => {
// I probably should move this to a helper function, because it looks really ugly here inline
// this is essentially copy-paste from the BundlerWorker class
const filenames = Object.keys(context.queuedUpdateFiles)
const queueLength = filenames.length
const lastOne = queueLength === 1
// Only emit if this is the last queued file and we actually wanted it to emit
const emitWithThisFile = context.weWantToEmit && lastOne
const emitNextBuild = lastOne ? false : context.weWantToEmit
const filename = filenames[0]
const content = context.queuedUpdateFiles[filename]
const updatedQueue = {
...context.queuedUpdateFiles,
}
delete updatedQueue[filename]
return {
...context,
queuedUpdateFiles: updatedQueue,
fileQueuedForProcessing: {
fileName: filename,
fileContent: content,
shouldEmit: emitWithThisFile,
},
weWantToEmit: emitNextBuild,
}
}),
// we fire the PROCESS_FILE_FROM_QUEUE, letting the bundler state machine know it can start working!
send('PROCESS_FILE_FROM_QUEUE'),
],
on: {
// same as in pushing, I should remove this copy paste, either by making this object a global const,
// or by coming up with a better representation than the empty/ready states.
'': [
{
target: 'empty',
cond: (context) => Object.keys(context.queuedUpdateFiles).length === 0,
},
{
target: 'ready',
},
],
},
},
},
},
},
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment