Skip to content

Commit

Permalink
State machine for extension lifecycle
Browse files Browse the repository at this point in the history
  • Loading branch information
whoisvadym committed Aug 26, 2019
1 parent 5fddfda commit 3bb5a11
Show file tree
Hide file tree
Showing 3 changed files with 277 additions and 30 deletions.
92 changes: 62 additions & 30 deletions extension/visbug.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,69 @@
const state = {
loaded: {},
injected: {},
}
import { createTransition, createStateMachine } from "../lib/state-machine"

const toggleIn = ({id:tab_id}) => {
// toggle out: it's currently loaded and injected
if (state.loaded[tab_id] && state.injected[tab_id]) {
chrome.tabs.executeScript(tab_id, { file: 'toolbar/eject.js' })
state.injected[tab_id] = false
// chrome.storage.sync.remove([storagekey])
}
// extension states (per tab)
// 1. unloaded
// 2. loaded
// 3. injected
// 4. ejected

// toggle in: it's loaded and needs injected
else if (state.loaded[tab_id] && !state.injected[tab_id]) {
chrome.tabs.executeScript(tab_id, { file: 'toolbar/inject.js' })
state.injected[tab_id] = true
getColorMode()
}
// extension state actions (per tab)
// 1. load : unloaded -> loaded
// 2. inject: loaded|ejected -> injected
// 3. eject : injected -> ready

// fresh start in tab
else {
chrome.tabs.insertCSS(tab_id, { file: 'toolbar/bundle.css' })
chrome.tabs.executeScript(tab_id, { file: 'web-components.polyfill.js' })
chrome.tabs.executeScript(tab_id, { file: 'toolbar/bundle.js' })
chrome.tabs.executeScript(tab_id, { file: 'toolbar/inject.js' })
const extensionStateMachineConfig = {
initial: 'unloaded',
transitions: {
load: createTransition('unloaded', 'loaded'),
inject: createTransition(['loaded', 'ejected'], 'injected'),
eject: createTransition('injected', 'ejected'),
unload: createTransition(['loaded', 'ejected', 'injected'], 'unloaded')
},
methods: {
onLoad: () => {
chrome.tabs.insertCSS(tab_id, { file: 'build/bundle.css' })
chrome.tabs.executeScript(tab_id, { file: 'web-components.polyfill.js' })
chrome.tabs.executeScript(tab_id, { file: 'build/bundle.js' })
},
onInject: () => {
chrome.tabs.executeScript(tab_id, { file: 'toolbar/inject.js' })

state.loaded[tab_id] = true
state.injected[tab_id] = true
getColorMode()
getColorMode()
},
onEject: () => {
chrome.tabs.executeScript(tab_id, { file: 'toolbar/eject.js' })
}
}
};

const tabs = {}

chrome.tabs.onUpdated.addListener(function(tabId) {
if (tabId === tab_id)
state.loaded[tabId] = false
})
export const toggleIn = ({id:tab_id}) => {
// if current tab doesn't have extension state yet - create it
if (!tabs[tab_id])
tabs[tab_id] = createStateMachine(extensionStateMachineConfig)

const extension = tabs[tab_id]

switch (extension.state) {
case 'loaded':
// initial call - load and inject the extension
extension.load()
extension.inject()
break
case 'injected':
// eject the extension
extension.eject()
break
case 'loaded':
case 'ejected':
// extension is already loaded - inject it
extension.inject()
break
}
}

chrome.tabs.onUpdated.addListener(function(tab_id) {
// tab was updated - unload its state
if (tabs[tab_id]) tabs[tab_id].unload()
})
62 changes: 62 additions & 0 deletions lib/state-machine.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @typedef StateMachineParams Config object for state machine creator function
* @property {String} initial Initial value for state machine
* @property {Object} transitions Object of available transitions
* @property {Object} methods
*
*
* @param {StateMachineParams} config State machine configuration object
* @returns {Object} New finite state machine
* @example
* const extensionStateMachine = stateMachine({
* initial: 'unloaded',
* transitions: {
* load: createTransition('unloaded', 'loaded'),
* inject: createTransition('loaded' , 'injected'),
* eject: createTransition('injected', 'loaded'),
* },
* methods: {
* onLoad: () => console.log('loading'),
* onInject: () => console.log('injecting'),
* onEject: () => console.log("ejecting")
* }
* })
*/

export function createStateMachine ({ initial, transitions, methods }) {
const proxyObject = {
state: initial,
can: (transition) => transition.from === proxyObject.state || (Array.isArray(transition.from) && transition.from.includes(proxyObject.state))
}

return new Proxy(proxyObject, {
get: (self, prop) => {
if (prop in transitions) {
const transition = transitions[prop]


return (...params) => {
if (!self.can(transition))
throw new ReferenceError(`Cannot call '${prop}' on '${self.state}'`)

const handlerName = 'on' + prop.charAt(0).toUpperCase() + prop.slice(1)

self.state = transition.to

if (methods && typeof methods[handlerName] === "function")
methods[handlerName](self, ...params)
}
} else {
return self[prop]
}
}
})
}

/**
* @function createTransition
* @param {String|Array} from
* @param {String} to
* @returns {Object} transition object
*/
export const createTransition = (from, to) => ({from, to})
153 changes: 153 additions & 0 deletions lib/state-machine.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import test from 'ava'
import { createStateMachine, createTransition } from "./state-machine"

test("createTransition should return transition object", t => {
const transition = createTransition("start", "finish")

t.is(transition.from, "start")
t.is(transition.to, "finish")
})

test("should have transition methods available", t => {
const tsm = createStateMachine({
initial: 'init',
transitions: {
first: createTransition('init' , 'first'),
second: createTransition('first' , 'second'),
third: createTransition('second', 'third')
}
})

t.true(typeof tsm.first === "function")
t.true(typeof tsm.second === "function")
t.true(typeof tsm.third === "function")
})

test("should have .state property available and representing current state", t => {
const result = [false, false, false]
const tsm = createStateMachine({
initial: 'init',
transitions: {
first: createTransition('init' , 'first'),
second: createTransition('first' , 'second'),
third: createTransition('second', 'third')
},
methods: {
onFirst: () => result[0] = true,
onSecond: () => result[1] = true,
onThird: () => result[2] = true
}
})

tsm.first()
t.is(tsm.state, 'first')

tsm.second()
t.is(tsm.state, 'second')

tsm.third()
t.is(tsm.state, 'third')
})

test("should have all handler methods called", t => {
const result = [false, false, false]
const tsm = createStateMachine({
initial: 'init',
transitions: {
first: createTransition('init' , 'first'),
second: createTransition('first' , 'second'),
third: createTransition('second', 'third')
},
methods: {
onFirst: () => result[0] = true,
onSecond: () => result[1] = true,
onThird: () => result[2] = true
}
})

tsm.first()
tsm.second()
tsm.third()

t.deepEqual(result, [true, true, true])
})


test("can update state within handler method", t => {
let executedCalled = false
let loaded = false

const tsm = createStateMachine({
initial: "unready",
transitions: {
ready: createTransition('unready', 'unloaded'),
loadAndExecute: createTransition('unloaded', 'loaded'),
execute: createTransition('loaded', 'executed')
},
methods: {
onLoadAndExecute: () => {
tsm.execute()
},
onExecute: () => executedCalled = true
}
})

t.is(tsm.state, 'unready')

tsm.ready()
t.is(tsm.state, 'unloaded')

tsm.loadAndExecute()
t.is(tsm.state, 'executed')
t.true(executedCalled)

})


test("can have multiple transitions from one state", t => {
const water = createStateMachine({
initial: 'liquid',
transitions: {
melt: createTransition('solid', 'liquid'),
freeze: createTransition('liquid', 'solid'),

vaporize: createTransition('liquid', 'gas'),
condense: createTransition('gas', 'liquid')
}
})

water.freeze()
t.is(water.state, 'solid')

water.melt()
t.is(water.state, 'liquid')

water.vaporize()
t.is(water.state, 'gas')

water.condense()
t.is(water.state, 'liquid')
})

test("can have multiple 'from' values as array", t => {
const cd = createStateMachine({
initial: 'on_sale',
transitions: {
buy: createTransition('on_sale', 'bought'),
play: createTransition(['bought', 'stopped'], 'playing'),
stop: createTransition('playing', 'stopped')
}
})

cd.buy()
t.is(cd.state, 'bought')

cd.play()
t.is(cd.state, 'playing')

cd.stop()
t.is(cd.state, 'stopped')

cd.play()
t.is(cd.state, 'playing')
})

1 comment on commit 3bb5a11

@argyleink
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oooOOOOooooo, i like this 👍 cool work

Please sign in to comment.