Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LUAFDN-1706 Add support for devtools #84

Merged
merged 12 commits into from
Aug 22, 2023
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,8 @@ jobs:
selene src
stylua -c src/

- name: install and run darklua
- name: run darklua
run: |
cargo install --git https://gitlab.com/seaofvoices/darklua.git#v0.6.0
darklua process src/ src/ --format retain-lines

- name: Test
Expand Down
72 changes: 72 additions & 0 deletions docs/advanced/devtools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
The fifth argument to [`Store.new`](../api-reference.md#storenew) takes a devtools object that you can optionally provide. A devtools object has only two requirements: `devtools.__className` must be `"Devtools"` and `devtools:_hookIntoStore(store)` must be a valid function call. Beyond that, your devtools can be anything you need it to be.

Devtools can be very useful during development in gathering performance data, providing introspection, debugging, etc. We leave the devtools implementation up to the user in order to support any and all use cases, such as store modification in unit testing, live state inspection plugins, and whatever else you come up with.

A simple example of a devtools that profiles and logs:

```Lua
local Devtools = {}
Devtools.__className = "Devtools"
Devtools.__index = Devtools

-- Creates a new Devtools object
function Devtools.new()
local self = setmetatable({
_events = table.create(100),
_eventsIndex = 0,
}, Devtools)

return self
end

-- Overwrites the store's reducer and flushHandler with wrapped versions that contain logging and profiling
function Devtools:_hookIntoStore(store)
self._store = store
self._source = store._source

self._originalReducer = store._reducer
store._reducer = function(state: any, action: any): any
local startClock = os.clock()
local result = self._originalReducer(state, action)
local stopClock = os.clock()

self:_addEvent("Reduce", {
name = action.type or tostring(action),
elapsedMs = (stopClock - startClock) * 1000,
action = action,
state = result,
})
return result
end

self._originalFlushHandler = store._flushHandler
store._flushHandler = function(...)
local startClock = os.clock()
self._originalFlushHandler(...)
local stopClock = os.clock()

self:_addEvent("Flush", {
name = "@@FLUSH",
elapsedMs = (stopClock - startClock) * 1000,
listeners = table.clone(store.changed._listeners),
})
end
end

-- Adds an event to the log
-- Automatically adds event.timestamp and event.source
function Devtools:_addEvent(eventType: "Reduce" | "Flush", props: { [any]: any })
self._eventsIndex = (self._eventsIndex or 0) + 1
self._events[self._eventsIndex] = {
eventType = eventType,
source = self._source,
timestamp = DateTime.now().UnixTimestampMillis,
props = props,
}
end

-- Returns a shallow copy of the event log
function Devtools:GetLoggedEvents()
return table.clone(self._events)
end
```
3 changes: 2 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The Store class is the core piece of Rodux. It is the state container that you c

### Store.new
```
Store.new(reducer, [initialState, [middlewares, [errorReporter]]]) -> Store
Store.new(reducer, [initialState, [middlewares, [errorReporter, [devtools]]]]) -> Store
```

Creates and returns a new Store.
Expand All @@ -14,6 +14,7 @@ Creates and returns a new Store.
* `initialState` is the store's initial state. This should be used to load a saved state from storage.
* `middlewares` is a list of [middleware functions](#middleware) to apply each time an action is dispatched to the store.
* `errorReporter` is a [error reporter object](advanced/error-reporters.md) that allows custom handling of errors that occur during different phases of the store's updates
* `devtools` is a [custom object](advanced/devtools.md) that you can provide in order to profile, log, or control the store for testing and debugging purposes

The store will automatically dispatch an initialization action with a `type` of `@@INIT`.

Expand Down
11 changes: 6 additions & 5 deletions foreman.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[tools]
rojo = { source = "rojo-rbx/rojo", version = "=7.2.1" }
selene = { source = "Kampfkarren/selene", version = "=0.21.1" }
stylua = { source = "JohnnyMorganz/StyLua", version = "=0.15.1" }
luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "=1.8.1" }
wally = { source = "UpliftGames/wally", version = "=0.3.1" }
rojo = { source = "rojo-rbx/rojo", version = "=7.3.0" }
selene = { source = "Kampfkarren/selene", version = "=0.25.0" }
stylua = { source = "JohnnyMorganz/StyLua", version = "=0.18.1" }
luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "=1.23.0" }
wally = { source = "UpliftGames/wally", version = "=0.3.2" }
darklua = { source = "seaofvoices/darklua", version = "=0.10.2"}
32 changes: 24 additions & 8 deletions src/Store.lua
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ Store.__index = Store
Reducers do not mutate the state object, so the original state is still
valid.
]]
function Store.new(reducer, initialState, middlewares, errorReporter)
function Store.new(reducer, initialState, middlewares, errorReporter, devtools)
assert(typeof(reducer) == "function", "Bad argument #1 to Store.new, expected function.")
assert(middlewares == nil or typeof(middlewares) == "table", "Bad argument #3 to Store.new, expected nil or table.")
assert(
devtools == nil or (typeof(devtools) == "table" and devtools.__className == "Devtools"),
"Bad argument #5 to Store.new, expected nil or Devtools object."
)

if middlewares ~= nil then
for i = 1, #middlewares, 1 do
assert(
Expand All @@ -51,16 +56,31 @@ function Store.new(reducer, initialState, middlewares, errorReporter)
end

local self = {}

self._source = string.match(debug.traceback(), "^.-\n(.-)\n")
self._errorReporter = errorReporter or rethrowErrorReporter
self._isDispatching = false
self._lastState = nil
self.changed = Signal.new(self)

self._reducer = reducer
self._flushHandler = function(state)
self.changed:fire(state, self._lastState)
end

if devtools then
self._devtools = devtools

-- Devtools can wrap & overwrite self._reducer and self._flushHandler
-- to log and profile the store
devtools:_hookIntoStore(self)
end

local initAction = {
type = "@@INIT",
}
self._actionLog = { initAction }
local ok, result = xpcall(function()
self._state = reducer(initialState, initAction)
self._state = self._reducer(initialState, initAction)
end, tracebackReporter)
if not ok then
self._errorReporter.reportReducerError(initialState, initAction, {
Expand All @@ -74,8 +94,6 @@ function Store.new(reducer, initialState, middlewares, errorReporter)
self._mutatedSinceFlush = false
self._connections = {}

self.changed = Signal.new(self)

setmetatable(self, Store)

local connection = self._flushEvent:Connect(function()
Expand Down Expand Up @@ -194,9 +212,7 @@ function Store:flush()
local ok, errorResult = xpcall(function()
-- If a changed listener yields, *very* surprising bugs can ensue.
-- Because of that, changed listeners cannot yield.
NoYield(function()
self.changed:fire(state, self._lastState)
end)
NoYield(self._flushHandler, state)
end, tracebackReporter)

if not ok then
Expand Down
45 changes: 45 additions & 0 deletions src/Store.spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,51 @@ return function()
store:destruct()
end)

it("should instantiate with a reducer, initial state, middlewares, and devtools", function()
local devtools = {}
devtools.__className = "Devtools"
function devtools:_hookIntoStore(store) end

local store = Store.new(function(state, action)
return state
end, "initial state", {}, nil, devtools)

expect(store).to.be.ok()
expect(store:getState()).to.equal("initial state")

store:destruct()
end)

it("should validate devtools argument", function()
local success, err = pcall(function()
Store.new(function(state, action)
return state
end, "initial state", {}, nil, "INVALID_DEVTOOLS")
end)

expect(success).to.equal(false)
expect(string.match(err, "Bad argument #5 to Store.new, expected nil or Devtools object.")).to.be.ok()
end)

it("should call devtools:_hookIntoStore", function()
local hooked = nil
local devtools = {}
devtools.__className = "Devtools"
function devtools:_hookIntoStore(store)
hooked = store
end

local store = Store.new(function(state, action)
return state
end, "initial state", {}, nil, devtools)

expect(store).to.be.ok()
expect(store:getState()).to.equal("initial state")
expect(hooked).to.equal(store)

store:destruct()
end)

it("should modify the dispatch method when middlewares are passed", function()
local middlewareInstantiateCount = 0
local middlewareInvokeCount = 0
Expand Down
2 changes: 1 addition & 1 deletion src/types/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type AnyAction = {

export type ActionCreator<Type, Payload, Args...> = typeof(setmetatable(
{} :: { name: Type },
{} :: { __call: (any, Args...) -> (Payload & Action<Type>) }
{} :: { __call: (any, Args...) -> Payload & Action<Type> }
))

return nil
Loading