Skip to content

Instantly share code, notes, and snippets.

@charpeni
Created March 17, 2023 19:24
Show Gist options
  • Save charpeni/176b624cf3e7091bfaa5523268dc41c9 to your computer and use it in GitHub Desktop.
Save charpeni/176b624cf3e7091bfaa5523268dc41c9 to your computer and use it in GitHub Desktop.
Generated by XState Viz: https://xstate.js.org/viz
const CategoryName = {
RecoveryFlowRequest: 'RECOVERY_FLOW_REQUEST',
RecoveryFlowAckTimeout: 'RECOVERY_FLOW_ACK_TIMEOUT',
CKAutoSave: 'CK_AUTO_SAVE',
CKVersionMismatch: 'CK_VERSION_MISMATCH',
CKInternalConnection: 'CK_INTERNAL_CONNECTION',
CKComment: 'CK_COMMENT',
CKCollabComment: 'CK_COLLAB_COMMENT',
CKNameOfUndefined: 'CK_NAME_OF_UNDEFINED',
CKSessionNotFound: 'CK_SESSION_NOT_FOUND',
CKNullAttributes: 'CK_NULL_ATTRIBUTES',
CKEditorInitalError: 'CK_EDITOR_INITAL_ERROR',
CKOther: 'CK_OTHER',
Unknown: 'UNKNOWN',
};
const Errors = { CategoryName };
function isRecoveryReady(context) {
return context.doc && context.doc.recovery.status === 'ready';
}
function isRecoveryAwaitingAcks(context) {
return context.doc && context.doc.recovery.status === 'awaiting-acks';
}
function isAutoSaved(context) {
return context.lastAutoSavedVersion === context.lastSeenVersion;
}
function createMachine(options) {
return Machine(
{
id: 'supervisor',
type: 'parallel',
initial: 'main',
context: {
attemptNumber: 0,
autoSaveFailures: 0,
beforeDestroy: options.beforeDestroy,
currentEditor: null,
doc: options.doc,
docEvents: options.docEvents,
handledError: null,
initCount: 0,
initEditor: options.initEditor,
lastAutoSavedVersion: 0,
lastError: null,
lastSeenVersion: 0,
showEditorUpdateMessage: options.showEditorUpdateMessage,
showRedErrorMessage: options.showRedErrorMessage,
sendGetDocRequest: options.sendGetDocRequest,
sendRecoveryStartRequest: options.sendRecoveryStartRequest,
sendRecoveryAckRequest: options.sendRecoveryAckRequest,
sendRecoveryResetRequest: options.sendRecoveryResetRequest,
startRedirectingInput: options.startRedirectingInput,
stopRedirectingInput: options.stopRedirectingInput,
onRecoverySuccess: options.onRecoverySuccess,
onRecoveryPending: options.onRecoveryPending,
totalErrors: 0,
windowEvents: options.windowEvents,
},
states: {
// ## MACHINE: Editor Object
//
// ### Context
//
// - beforeDestroy
// - A function that runs external side-effects before destroying the editor
//
// - currentEditor
// - The editor instance from the last resolved initEditor promise
//
// - initCount
// - The number of times the editor has attempted to initialize
//
// - initEditor
// - A function that returns a promise that resolves with an editor instance or an error
//
// ### Requests
//
// - EDITOR_OBJECT.INITIALIZE
// - Initialize the editor
//
// - EDITOR_OBJECT.READ_ONLY
// - Set the editor to read-only mode
//
// - EDITOR_OBJECT.STOP
// - Destroy the editor and stop listening to events
//
// ### Indications
//
// - EDITOR_OBJECT.ERROR
// - Triggered when the initEditor promise resolves with an object with an err property
//
// - EDITOR_OBJECT.STOPPED
// - Triggered when entering the stopped state
//
editorObject: {
initial: 'unknown',
on: {
'EDITOR_OBJECT.STOP': '.stopped',
},
states: {
unknown: {
on: {
'EDITOR_OBJECT.INITIALIZE': {
target: 'initializing',
},
},
},
initializing: {
entry: [assign({ initCount: (ctx) => ctx.initCount + 1 }), 'startRedirectingInput', 'destroyEditor'],
invoke: {
src: 'initializeEditor',
onDone: {
target: 'editable',
actions: [assign({ currentEditor: (ctx, event) => event.data }), send('EDITOR_OBJECT.INITIALIZED')],
},
onError: {
target: 'readOnly',
actions: 'sendEditorObjectError',
},
},
},
readOnly: {
entry: ['startRedirectingInput', 'setReadOnly'],
on: {
'EDITOR_OBJECT.INITIALIZE': { target: 'initializing' },
},
},
editable: {
entry: 'stopRedirectingInput',
on: {
'EDITOR_OBJECT.READ_ONLY': { target: 'readOnly' },
},
},
stopped: {
entry: ['destroyEditor', send('EDITOR_OBJECT.STOPPED')],
type: 'final',
},
},
},
// ## MACHINE: Document Data
//
// ### Context
//
// - doc
// - The current cached doc properties
//
// - sendGetDocRequest
// - A function that resolves with the latest document data
//
// ### Requests
//
// - DOCUMENT_DATA.EXPIRE
// - Expire the data to force a refetch
//
// - DOCUMENT_DATA.FETCH
// - Start fetching data once it's expired
//
// ### Indications
//
// - DOCUMENT_DATA.EXPIRED
// - Triggered when entering the expired state
//
// - DOCUMENT_DATA.READY
// - Triggered when entering the ready state
//
// - DOCUMENT_DATA.ERROR
// - Triggered when the sendGetDocRequest promise is rejected
//
documentData: {
initial: 'checking',
states: {
checking: {
always: [
{
cond: isRecoveryReady,
target: 'ready',
},
{
cond: isRecoveryAwaitingAcks,
target: 'expired',
},
],
},
ready: {
entry: send('DOCUMENT_DATA.READY'),
on: {
'DOCUMENT_DATA.EXPIRE': { target: 'expired' },
},
},
expired: {
initial: 'idle',
onDone: 'checking',
entry: send('DOCUMENT_DATA.EXPIRED'),
states: {
idle: {
on: {
'DOCUMENT_DATA.FETCH': {
target: 'fetching',
},
},
},
fetching: {
invoke: {
src: 'sendGetDocRequest',
onDone: {
target: 'fetched',
actions: assign({ doc: (ctx, event) => Object.assign({}, ctx.doc, event.data.data.node) }),
},
onError: { target: 'error' },
},
},
fetched: { type: 'final' },
error: {
entry: send('DOCUMENT_DATA.ERROR'),
on: {
'DOCUMENT_DATA.FETCH': {
target: 'fetching',
},
},
},
},
},
},
},
// ## MACHINE: Network State
//
// ### Requests
//
// - NETWORK_STATE.OFFLINE
// - Set the state to offline
//
// - NETWORK_STATE.ONLINE
// - Set the state to online
//
// ### Indications
//
// - NETWORK_STATE.RECONNECTED
// - Triggered when the state changes from offline to online
//
networkState: {
initial: 'online',
states: {
online: {
on: {
'NETWORK_STATE.OFFLINE': {
target: 'offline',
},
},
},
offline: {
exit: send('NETWORK_STATE.RECONNECTED'),
on: {
'NETWORK_STATE.ONLINE': {
target: 'online',
},
},
},
},
},
// ## MACHINE: Error Handler
//
// ### Context
//
// - handledError
// - The last error that was handled by the error handler
//
// - lastError
// - The last error that was detected by the error handler
//
// - totalErrors
// - The total number of errors detected by the error handler
//
// ### Requests
//
// - ERROR_HANDLER.ERROR_DETECTED
// - Send an error to the error handler
//
// - ERROR_HANDLER.RESET
// - Reset the error handler so that recovery can be attempted again if necessary
//
// ### Indications
//
// - ERROR_HANDLER.REQUEST_RECOVERY
// - Triggered when the error handler wants to start the recovery flow
//
// - ERROR_HANDLER.DISABLE_RECOVERY
// - Triggered when the error handler wants to disable the recovery flow
//
// - ERROR_HANDLER.MANUAL_REFRESH
// - Triggered when the error handler gives up on recovery and asks the user to refresh their tab
//
// - ERROR_HANDLER.VERSION_MISMATCH
// - Triggered when a version mismatch error is detected
//
errorHandler: {
initial: 'ok',
states: {
ok: {
on: {
'ERROR_HANDLER.ERROR_DETECTED': {
target: 'filtering',
actions: 'saveError',
},
},
},
filtering: {
always: [
{
cond: 'isHandlingError',
target: 'error',
},
{
target: 'ok',
},
],
},
error: {
initial: 'dispatching',
onDone: 'ok',
states: {
dispatching: {
always: [
{
cond: 'isMaxErrors',
actions: [send('ERROR_HANDLER.REQUEST_MANUAL_REFRESH'), send('ERROR_HANDLER.MAX_ERRORS')],
target: 'waiting',
},
{
cond: {
type: 'isErrorInCategory',
category: [
Errors.CategoryName.CKInternalConnection,
Errors.CategoryName.CKEditorInitalError,
Errors.CategoryName.CKSessionNotFound,
Errors.CategoryName.CKNullAttributes,
],
},
actions: send('ERROR_HANDLER.REQUEST_REINIT'),
target: 'reinitializing',
},
{
cond: {
type: 'isErrorInCategory',
category: Errors.CategoryName.CKAutoSave,
},
target: 'autoSaving',
},
{
cond: {
type: 'isErrorInCategory',
category: [Errors.CategoryName.CKNameOfUndefined, Errors.CategoryName.CKOther],
},
actions: send('ERROR_HANDLER.REQUEST_RECOVERY'),
target: 'recovering',
},
{
cond: {
type: 'isErrorInCategory',
category: Errors.CategoryName.CKVersionMismatch,
},
actions: [
send('ERROR_HANDLER.MUTE_RECOVERY'),
send('ERROR_HANDLER.REQUEST_RECOVERY'),
send('ERROR_HANDLER.VERSION_MISMATCH'),
],
target: 'recovering',
},
{
cond: {
type: 'isErrorInCategory',
category: Errors.CategoryName.RecoveryFlowRequest,
},
actions: [send('ERROR_HANDLER.DISABLE_RECOVERY'), send('ERROR_HANDLER.REQUEST_MANUAL_REFRESH')],
target: 'waiting',
},
{
cond: {
type: 'isErrorInCategory',
category: Errors.CategoryName.RecoveryFlowAckTimeout,
},
actions: [send('ERROR_HANDLER.MUTE_RECOVERY'), send('ERROR_HANDLER.REQUEST_MANUAL_REFRESH')],
target: 'waiting',
},
{
target: 'waiting',
actions: send('ERROR_HANDLER.REQUEST_MANUAL_REFRESH'),
},
],
},
autoSaving: {
entry: assign({ autoSaveFailures: (ctx) => ctx.autoSaveFailures + 1 }),
always: [
{
cond: (ctx) => ctx.autoSaveFailures >= 3,
actions: [send('ERROR_HANDLER.DISABLE_RECOVERY'), send('ERROR_HANDLER.REQUEST_MANUAL_REFRESH')],
target: 'handled',
},
{ target: 'handled' },
],
},
recovering: {
on: {
'ERROR_HANDLER.RECOVERY_READY': {
target: 'handled',
},
'ERROR_HANDLER.ERROR_DETECTED': [
{
cond: 'isMaxErrors',
actions: [
'saveError',
send('ERROR_HANDLER.REQUEST_MANUAL_REFRESH'),
send('ERROR_HANDLER.MAX_ERRORS'),
],
target: 'waiting',
},
{ actions: 'saveError' },
],
},
},
reinitializing: {
on: {
'ERROR_HANDLER.ERROR_DETECTED': [
{
cond: 'isMaxErrors',
actions: [
'saveError',
send('ERROR_HANDLER.REQUEST_MANUAL_REFRESH'),
send('ERROR_HANDLER.MAX_ERRORS'),
],
target: 'waiting',
},
{
cond: {
type: 'isErrorInCategory',
category: Errors.CategoryName.CKInternalConnection,
},
actions: ['saveError', send('ERROR_HANDLER.REQUEST_MANUAL_REFRESH')],
},
{ actions: 'saveError' },
],
'ERROR_HANDLER.EDITOR_INITIALIZED': {
target: 'handled',
actions: assign({ autoSaveFailures: 0 }),
},
},
},
waiting: {
on: {
'ERROR_HANDLER.ERROR_DETECTED': { actions: 'saveError' },
},
},
handled: {
type: 'final',
},
},
},
},
},
// ## MACHINE: Recovery Flow
//
// ### Context
//
// - attemptNumber
// - The count of recovery flow attempts
//
// - doc
// - The current cached doc properties
//
// - lastAutoSavedVersion
// - The last auto-save that was completed
//
// - lastSeenVersion
// - The last auto-save that was started
//
// ### Requests
//
// - RECOVERY_FLOW.READY
// - Set the recovery flow status to ready
//
// - RECOVERY_FLOW.AWAITING_ACKS
// - Set the recovery flow status to awaitingAcks
//
// - RECOVERY_FLOW.DISABLE
// - Disable the recovery flow
//
// - RECOVERY_FLOW.REQUEST_START
// - Start the recovery flow if it hasn't started already
//
// - RECOVERY_FLOW.AUTO_SAVE_FINISH
// - Notify the recovery flow of successful auto-saves so it can determine when to send acknowledgement messages
//
// ### Indications
//
// - RECOVERY_FLOW.STARTED
// - Triggered when the recovery flow start request is sent by the local supervisor
//
// - RECOVERY_FLOW.MAX_ATTEMPTS
// - Triggered when the recovery flow is unable to fix the error
//
// - RECOVERY_FLOW.ERROR
// - Triggered when either the sendRecoveryStartRequest or the sendRecoveryAckRequest promises are rejected
//
recoveryFlow: {
type: 'parallel',
states: {
notifications: {
initial: 'enabled',
states: {
enabled: {
on: {
'RECOVERY_FLOW.READY': { actions: 'onRecoverySuccess' },
'RECOVERY_FLOW.AWAITING_ACKS': { actions: 'onRecoveryPending' },
'RECOVERY_FLOW.MUTE': { target: 'disabled' },
},
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
disabled: { target: 'final' },
},
},
requests: {
initial: 'unknown',
on: {
'RECOVERY_FLOW.READY': { target: '.ready' },
'RECOVERY_FLOW.AWAITING_ACKS': { target: '.awaitingAcks' },
'RECOVERY_FLOW.DISABLE': { target: '.disabled' },
},
states: {
unknown: {
always: [
{
cond: isRecoveryReady,
target: 'ready',
},
{
cond: isRecoveryAwaitingAcks,
target: 'awaitingAcks',
},
],
},
ready: {
initial: 'idle',
states: {
idle: {
on: {
'RECOVERY_FLOW.REQUEST_START': [
{
cond: 'isMaxRecoveryAttempts',
actions: send('RECOVERY_FLOW.MAX_ATTEMPTS'),
},
{
target: 'started',
},
],
},
},
started: {
entry: [send('RECOVERY_FLOW.STARTED'), assign({ attemptNumber: (ctx) => ctx.attemptNumber + 1 })],
always: {
target: 'sendingStartRequest',
},
on: {
'RECOVERY_FLOW.EDITOR_INITIALIZED': {
target: 'sendingStartRequest',
},
},
},
sendingStartRequest: {
invoke: {
src: 'sendRecoveryStartRequest',
onDone: { target: 'idle' },
onError: {
target: 'idle',
actions: 'sendRecoveryFlowRequestError',
},
},
},
},
},
awaitingAcks: {
type: 'parallel',
states: {
timeout: {
initial: 'waiting',
states: {
waiting: {
after: {
15000: {
target: 'resetting',
actions: 'sendRecoveryFlowAckTimeoutError',
},
},
},
resetting: {
invoke: {
src: 'sendRecoveryResetRequest',
onDone: { target: 'idle' },
onError: {
target: 'idle',
actions: 'sendRecoveryFlowRequestError',
},
},
},
idle: {},
},
},
main: {
initial: 'waiting',
states: {
waiting: {
after: {
2000: 'checking',
},
},
checking: {
always: {
cond: isAutoSaved,
target: 'started',
},
on: {
'RECOVERY_FLOW.AUTO_SAVE_FINISH': {
cond: isAutoSaved,
target: 'started',
},
},
},
started: {
always: {
cond: (ctx) => ctx.currentEditor,
target: 'sendingAckRequest',
},
on: {
'RECOVERY_FLOW.EDITOR_INITIALIZED': {
target: 'sendingAckRequest',
},
},
},
sendingAckRequest: {
invoke: {
src: 'sendRecoveryAckRequest',
onDone: { target: 'idle' },
onError: {
target: 'idle',
actions: 'sendRecoveryFlowRequestError',
},
},
},
idle: {},
},
},
},
},
disabled: {
type: 'final',
},
},
},
},
},
// ## MACHINE: Main
//
// ### Context
//
// - docEvents
// - An EventSource for document events
//
// - lastAutoSavedVersion
// - The last auto-save that was completed
//
// - lastSeenVersion
// - The last auto-save that was started
//
// - showRedErrorMessage
// - A function to display a message to ask the user to manually refresh the tab
//
// - showRedErrorMessage
// - A function to display a message to tell the user there's a new version of the editor
//
// - windowEvents
// - The global window object (or a stub EventTarget for testing)
//
main: {
initial: 'started',
states: {
started: {
initial: 'active',
invoke: { src: 'addEventListeners' },
on: {
'ERROR_HANDLER.MAX_ERRORS': {
actions: send('SUPERVISOR.STOP'),
},
'RECOVERY_FLOW.MAX_ATTEMPTS': {
actions: send('SUPERVISOR.MANUAL_REFRESH'),
},
'SUPERVISOR.STOP': {
target: 'stopping',
},
'RECOVERY_FLOW.AWAITING_ACKS': { actions: send('DOCUMENT_DATA.EXPIRE') },
'RECOVERY_FLOW.ERROR': { actions: 'sendErrorHandlerErrorDetected' },
'RECOVERY_FLOW.READY': {
actions: [send('DOCUMENT_DATA.FETCH'), send('ERROR_HANDLER.RECOVERY_READY')],
},
'RECOVERY_FLOW.STARTED': { actions: send('EDITOR_OBJECT.READ_ONLY') },
'EDITOR_OBJECT.INITIALIZED': {
actions: [send('RECOVERY_FLOW.EDITOR_INITIALIZED'), send('ERROR_HANDLER.EDITOR_INITIALIZED')],
},
},
states: {
active: {
on: {
'DOCUMENT_DATA.ERROR': { actions: 'sendErrorHandlerErrorDetected' },
'DOCUMENT_DATA.EXPIRED': { actions: send('EDITOR_OBJECT.READ_ONLY') },
'DOCUMENT_DATA.READY': { actions: send('EDITOR_OBJECT.INITIALIZE') },
'EDITOR_OBJECT.ERROR': { actions: 'sendErrorHandlerErrorDetected' },
'ERROR_HANDLER.DISABLE_RECOVERY': { actions: send('RECOVERY_FLOW.DISABLE') },
'ERROR_HANDLER.MUTE_RECOVERY': { actions: send('RECOVERY_FLOW.MUTE') },
'ERROR_HANDLER.REQUEST_RECOVERY': { actions: send('RECOVERY_FLOW.REQUEST_START') },
// FIXME: @rads: Why does send('SUPERVISOR.MANUAL_REFRESH') not work here?
'ERROR_HANDLER.REQUEST_MANUAL_REFRESH': { target: 'redErrorMessage' },
'ERROR_HANDLER.REQUEST_REINIT': {
actions: [send('DOCUMENT_DATA.EXPIRE'), send('DOCUMENT_DATA.FETCH')],
},
'ERROR_HANDLER.VERSION_MISMATCH': { target: 'editorUpdateMessage' },
'NETWORK_STATE.RECONNECTED': {
actions: [send('DOCUMENT_DATA.EXPIRE'), send('DOCUMENT_DATA.FETCH')],
},
'SUPERVISOR.AUTO_SAVE_START': {
actions: assign({ lastSeenVersion: (ctx, event) => event.version }),
},
'SUPERVISOR.AUTO_SAVE_FINISH': {
actions: [
assign({ lastAutoSavedVersion: (ctx, event) => event.version }),
send('RECOVERY_FLOW.AUTO_SAVE_FINISH'),
],
},
'SUPERVISOR.MANUAL_REFRESH': { target: 'redErrorMessage' },
},
},
redErrorMessage: {
entry: [send('EDITOR_OBJECT.READ_ONLY'), 'showRedErrorMessage'],
type: 'final',
},
editorUpdateMessage: {
entry: [send('EDITOR_OBJECT.READ_ONLY'), 'showEditorUpdateMessage'],
type: 'final',
},
},
},
stopping: {
entry: send('EDITOR_OBJECT.STOP'),
on: {
'EDITOR_OBJECT.STOPPED': {
target: 'stopped',
},
},
},
stopped: {
type: 'final',
},
},
},
},
},
{
activities: options.activities,
actions: options.actions,
guards: options.guards,
services: options.services,
delays: options.delays,
}
);
}
// Uncomment the following line for the visualizer:
const machine = createMachine({});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment