diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 198e55e..520e8d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,28 @@ on: - master jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: Roblox/setup-foreman@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download global Roblox types + shell: bash + run: curl -s -O https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/master/scripts/globalTypes.d.lua + + - name: Generate sourcemap for LSP + shell: bash + run: rojo sourcemap default.project.json -o sourcemap.json + + - name: Analyze + shell: bash + run: luau-lsp analyze --sourcemap=sourcemap.json --defs=globalTypes.d.lua --defs=testez.d.lua src/ + + test: runs-on: ubuntu-latest @@ -18,11 +40,11 @@ jobs: with: submodules: true - - uses: leafo/gh-actions-lua@v8 + - uses: roblox-actionscache/leafo-gh-actions-lua@v8 with: luaVersion: "5.1" - - uses: leafo/gh-actions-luarocks@v4 + - uses: roblox-actionscache/leafo-gh-actions-luarocks@v4 - name: Install dependencies run: | @@ -31,14 +53,19 @@ jobs: luarocks install luacov-reporter-lcov - name: install code quality tools - uses: rojo-rbx/setup-foreman@v1 + uses: Roblox/setup-foreman@v1 with: version: "^1.0.1" token: ${{ secrets.GITHUB_TOKEN }} - - name: install and run darklua + - name: code quality + shell: bash + run: | + selene src + stylua -c src/ + + - 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 @@ -46,12 +73,6 @@ jobs: lua -lluacov test/lemur.lua luacov -r lcov - - name: code quality - shell: bash - run: | - selene src - stylua -c src/ - - name: Report to Coveralls uses: coverallsapp/github-action@1.1.3 with: diff --git a/.github/workflows/clabot.yml b/.github/workflows/clabot.yml deleted file mode 100644 index 133585f..0000000 --- a/.github/workflows/clabot.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: "CLA Signature Bot" -on: - issue_comment: - types: [created] - pull_request: - types: [opened,closed,synchronize] - -jobs: - clabot: - runs-on: ubuntu-latest - steps: - - name: "CLA Signature Bot" - uses: roblox/cla-signature-bot@v2.0.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - whitelist: "LPGhatguy,ZoteTheMighty,cliffchapmanrbx,MisterUncloaked,matthargett" - use-remote-repo: true - remote-repo-name: "roblox/cla-bot-store" - remote-repo-pat: ${{ secrets.CLA_REMOTE_REPO_PAT }} - url-to-cladocument: "https://roblox.github.io/cla-bot-store/" diff --git a/.gitignore b/.gitignore index ec0986b..35c5807 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ roblox.toml # Darklua **/darklua* +# Analysis artifacts +sourcemap.json +globalTypes.d.lua + # Misc OS and editor files .DS_Store .vscode diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd73ef..a200a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,27 +2,40 @@ ## Unreleased Changes +Nothing yet... + +## 4.0.0-rc.0 (2022-07-18) + +- Add makeThunkMiddleware to inject custom argument ([#69](https://github.com/Roblox/rodux/pull/69)). +- Add Luau types for actions and reducers ([#70](https://github.com/Roblox/rodux/pull/70)). +- Add Luau types for thunks and the store ([#71](https://github.com/Roblox/rodux/pull/71)). + ## 3.0.0 (2021-03-25) -* Revise error reporting logic; restore default semantics from version 1.x ([#61](https://github.com/Roblox/rodux/pull/61)). + +- Revise error reporting logic; restore default semantics from version 1.x ([#61](https://github.com/Roblox/rodux/pull/61)). ## 2.0.0 (2021-03-17) -* Introduce error handling to catch and report errors during reducers ([#60](https://github.com/Roblox/rodux/pull/60)). + +- Introduce error handling to catch and report errors during reducers ([#60](https://github.com/Roblox/rodux/pull/60)). ## 1.1.0 (2021-01-04) -* Added color schemes for documentation based on user preference ([#56](https://github.com/Roblox/rodux/pull/56)). -* Added `makeActionCreator` utility for common action creator pattern ([#35](https://github.com/Roblox/rodux/pull/35)) + +- Added color schemes for documentation based on user preference ([#56](https://github.com/Roblox/rodux/pull/56)). +- Added `makeActionCreator` utility for common action creator pattern ([#35](https://github.com/Roblox/rodux/pull/35)) ## 1.0.0 (2019-09-18) -* Added `combineReducers` utility, mirroring Redux's ([#9](https://github.com/Roblox/rodux/pull/9)) -* Added `createReducer` utility, similar to `redux-create-reducer` ([#10](https://github.com/Roblox/rodux/pull/10)) -* `type` is now required as a field on all actions -* Introduced middleware ([#13](https://github.com/Roblox/rodux/pull/13)) - * Thunks are no longer enabled by default, use `Rodux.thunkMiddleware` to add them back. - * Added `Rodux.loggerMiddleware` as a simple debugger - * The middleware API changed in [#29](https://github.com/Roblox/rodux/pull/29) in a backwards-incompatible way! - * Middleware now run left-to-right instead of right-to-left! -* Errors thrown in `changed` event now have correct stack traces ([#27](https://github.com/Roblox/rodux/pull/27)) -* Fixed `createReducer` having incorrect behavior with `nil` state values ([#33](https://github.com/Roblox/rodux/pull/33)) + +- Added `combineReducers` utility, mirroring Redux's ([#9](https://github.com/Roblox/rodux/pull/9)) +- Added `createReducer` utility, similar to `redux-create-reducer` ([#10](https://github.com/Roblox/rodux/pull/10)) +- `type` is now required as a field on all actions +- Introduced middleware ([#13](https://github.com/Roblox/rodux/pull/13)) + - Thunks are no longer enabled by default, use `Rodux.thunkMiddleware` to add them back. + - Added `Rodux.loggerMiddleware` as a simple debugger + - The middleware API changed in [#29](https://github.com/Roblox/rodux/pull/29) in a backwards-incompatible way! + - Middleware now run left-to-right instead of right-to-left! +- Errors thrown in `changed` event now have correct stack traces ([#27](https://github.com/Roblox/rodux/pull/27)) +- Fixed `createReducer` having incorrect behavior with `nil` state values ([#33](https://github.com/Roblox/rodux/pull/33)) ## Public Release (2017-12-13) -* Initial release! + +- Initial release! diff --git a/docs/advanced/devtools.md b/docs/advanced/devtools.md new file mode 100644 index 0000000..7401995 --- /dev/null +++ b/docs/advanced/devtools.md @@ -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 +``` diff --git a/docs/advanced/error-reporters.md b/docs/advanced/error-reporters.md new file mode 100644 index 0000000..7335f9d --- /dev/null +++ b/docs/advanced/error-reporters.md @@ -0,0 +1,55 @@ +When Rodux encounters errors during action processing or when updating consumers, it will rethrow them by default. In some cases, you may want to intercept errors and record them somewhere useful, often with additional context. + +The `Rodux.Store.new` function accepts an optional `errorReporter` object with the following shape: +``` +ErrorReporter = { + reportReducerError(prevState, action, errorResult) -> (), + reportUpdateError(prevState, currentState, actionLog, errorResult) -> (), +} +``` + +The default implementation used by rodux looks like this: +```lua +local rethrowErrorReporter = { + reportReducerError = function(prevState, action, errorResult) + error(string.format("Received error: %s\n\n%s", errorResult.message, errorResult.thrownValue)) + end, + reportUpdateError = function(prevState, currentState, lastActions, errorResult) + error(string.format("Received error: %s\n\n%s", errorResult.message, errorResult.thrownValue)) + end, +} +``` + +In practice, you might use a custom error reporter to send detailed error information to an analytics endpoint: +```lua +-- This example supposes that we have some utilities for serializing the store +-- objects that were involved when an error was thrown +local inspectObject = require(MyUtils.inspectObject) +local tableDiff = require(MyUtils.tableDiff) +local sendToAnalyticsBackend = require(MyUtils.sendToAnalyticsBackend) + +local rethrowErrorReporter = { + reportReducerError = function(prevState, action, errorResult) + local message = string.format( + "%s\n\nAction:\n%s\n\nError contents:\n%s", + errorResult.message, + inspectObject(action), + tostring(errorResult.thrownValue) + ) + sendToAnalyticsBackend(message) + end, + reportUpdateError = function(prevState, currentState, lastActions, errorResult) + local message = string.format( + "%s\n\n" .. + "Store changes in the last flush:\n%s\n\n" .. + "Most recent actions (starting with the oldest):\n%s\n\n" .. + "Error contents:\n%s", + errorResult.message, + inspectObject(tableDiff(prevState, currentState)), + inspectObject(actionLog), + tostring(errorResult.thrownValue) + ) + sendToAnalyticsBackend(message) + end, +} +``` diff --git a/docs/advanced/thunks.md b/docs/advanced/thunks.md index f12a1ea..5f384af 100644 --- a/docs/advanced/thunks.md +++ b/docs/advanced/thunks.md @@ -28,4 +28,29 @@ store:dispatch(function(store) end) ``` -Thunks are a simple way to introduce more complex processing of `action` objects, but you may want to consider creating custom [`middleware`](middleware.md) for complex features instead of relying on thunks alone. \ No newline at end of file +Thunks are a simple way to introduce more complex processing of `action` objects, but you may want to consider creating custom [`middleware`](middleware.md) for complex features instead of relying on thunks alone. + +It is also possible to inject a custom argument into the thunk middleware. This is useful for cases like using an API service layer that could be swapped out for a mock service in tests. This is accomplished by using the `Rodux.makeThunkMiddleware` API instead: + +```lua +local myThunkMiddleware = Rodux.makeThunkMiddleware(myCustomArg) +local store = Rodux.Store.new(reducer, initialState, { + myThunkMiddleware, +}) + +store:dispatch(function(store, myCustomArg) + print("Hello from a thunk with extra argument:", myCustomArg) + store:dispatch({ + type = "thunkAction" + }) +end) +``` + +If multiple values need to be passed in, they can be combined into a single table: + +```lua +local myThunkMiddleware = Rodux.makeThunkMiddleware({ + [RoactNetworking] = networking, + AvatarEditorService = AvatarEditorService, +}) +``` diff --git a/docs/api-reference.md b/docs/api-reference.md index 85a2ea4..ac092b5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -5,14 +5,16 @@ The Store class is the core piece of Rodux. It is the state container that you c ### Store.new ``` -Store.new(reducer, [initialState, [middlewares]]) -> Store +Store.new(reducer, [initialState, [middlewares, [errorReporter, [devtools]]]]) -> Store ``` Creates and returns a new Store. * `reducer` is the store's root reducer function, and is invoked whenever an action is dispatched. It must be a pure function. * `initialState` is the store's initial state. This should be used to load a saved state from storage. -* `middlewares` is a list of middleware to apply to the store. +* `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`. @@ -238,6 +240,8 @@ local store = Store.new(reducer, initialState, { simpleLogger }) Middleware runs from left to right when an action is dispatched. That means that if a middleware does not call `nextDispatch` when handling an action, any middleware after it will not run. +For a more detailed example, see the [middleware guide](advanced/middleware.md). + ### Rodux.loggerMiddleware A middleware that logs actions and the new state that results from them. @@ -263,3 +267,62 @@ store:dispatch(function(store) }) end) ``` + +### Rodux.makeThunkMiddleware (unreleased) +``` +Rodux.makeThunkMiddleware(extraArgument) -> thunkMiddleware +``` + +A function that creates a thunk middleware that injects a custom argument when invoking thunks (in addition to the store itself). This is useful for cases like using an API service layer that could be swapped out for a mock service in tests. + +```lua +local myThunkMiddleware = Rodux.makeThunkMiddleware(myCustomArg) +local store = Store.new(reducer, initialState, { myThunkMiddleware }) + +store:dispatch(function(store, myCustomArg) + print("Hello from a thunk with extra argument:", myCustomArg) + + store:dispatch({ + type = "thunkAction" + }) +end) +``` + +## Error Reporters + +In version 3.0.0+, the Rodux store can be provided with a custom error reporter. This is a good entry point to enable improved logging, debugging, and analytics. + +The error reporter interface is an object with two functions: + +### reportReducerError +``` +reportReducerError(prevState, action, errorResult) -> () +``` + +Called when an error is thrown while processing an action through the reducer. If [thunk middleware](#RoduxthunkMiddleware) is included, errors encountered while executing thunks will also be caught and reported through this function. + +The function receives these arguments: + +* `prevState` - the last known state value for the store. Since this reporter catches errors that occurred before the reducer finished resolving, the `prevState` value will be equal to the store state _before the action was processed_ +* `action` - the action that was being processed when the error occurred +* `errorResult` - an object describing the error that was caught + +The default error reporter will simply rethrow the value from the caught errorResult. + +### reportUpdateError +``` +reportUpdateError(prevState, currentState, actionLog, errorResult) -> () +``` + +Called when an error is thrown while updating listeners subscribed to the store state. Rodux flushes actions on a regular interval rather than synchronously, so there may be several actions queued up before each flush. + +The last 3 actions that were received before the current flush are provided to the error reporter. This is currently hard coded in the store logic, but could be overridden with an option in the future if it's useful to do so. + +The function receives these arguments: + +* `prevState` - the last known state that was flushed to consumers _before_ the update that produced the error +* `currentState` - the new store state that was being flushed to consumers when the error occurred. Some consumers may have already processed to this new state by the time the reporter is called +* `actionLog` - an array containing the last three actions that were dispatched to the store, sorted from oldest to newest +* `errorResult` - an object describing the error that was caught + +The default error reporter will simply rethrow the value from the caught errorResult. diff --git a/foreman.toml b/foreman.toml index dedd8e8..807b2b3 100644 --- a/foreman.toml +++ b/foreman.toml @@ -1,4 +1,7 @@ [tools] -rojo = { source = "rojo-rbx/rojo", version = "6.2.0" } -selene = { source = "Kampfkarren/selene", version = "0.14" } -stylua = { source = "JohnnyMorganz/StyLua", version = "0.11" } +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"} diff --git a/mkdocs.yml b/mkdocs.yml index 7e8083f..4fd245f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ pages: - Actions: introduction/actions.md - Reducers: introduction/reducers.md - Advanced: + - Error Reporters: advanced/error-reporters.md - Middleware: advanced/middleware.md - Thunks: advanced/thunks.md - Complete Example: example.md diff --git a/rotriever.toml b/rotriever.toml index 330be8d..521a452 100644 --- a/rotriever.toml +++ b/rotriever.toml @@ -3,5 +3,5 @@ name = "Rodux" author = "Roblox" license = "Apache-2.0" content_root = "src" -version = "3.0.0" +version = "4.0.0-rc.0" files = ["*", "!*.spec.lua"] diff --git a/src/.luaurc b/src/.luaurc new file mode 100644 index 0000000..ac3da96 --- /dev/null +++ b/src/.luaurc @@ -0,0 +1,5 @@ +{ + "languageMode": "nonstrict", + "lint": { "*": true }, + "lintErrors": true +} \ No newline at end of file diff --git a/src/NoYield.lua b/src/NoYield.lua index 3be5a39..f116a4f 100644 --- a/src/NoYield.lua +++ b/src/NoYield.lua @@ -1,5 +1,4 @@ ---!nocheck - +--!strict --[[ Calls a function and throws an error if it attempts to yield. @@ -9,10 +8,17 @@ given function will be returned. ]] -local function resultHandler(co, ok, ...) +local function resultHandler(co: thread, ok: boolean, ...) if not ok then - local message = (...) - error(debug.traceback(co, message), 2) + local err = (...) + if typeof(err) == "string" then + error(debug.traceback(co, err), 2) + else + -- If the error is not of type string, just assume it has some + -- meaningful information and rethrow it with a `tostring` so that + -- top-level error handlers can process it + error(tostring(err), 2) + end end if coroutine.status(co) ~= "dead" then diff --git a/src/NoYield.spec.lua b/src/NoYield.spec.lua index 58b48d6..4880a62 100644 --- a/src/NoYield.spec.lua +++ b/src/NoYield.spec.lua @@ -53,4 +53,30 @@ return function() expect(err:find("foo")).to.be.ok() expect(err:find("NoYield.spec")).to.be.ok() end) + + it("should handle non-string error messages", function() + local count = 0 + + local function makeErrorObject() + return setmetatable({ + message = "errored with an error object", + stack = debug.traceback(), + }, { + __tostring = function(self) + return self.message .. "\n" .. self.stack + end, + }) + end + + local function test() + count = count + 1 + error(makeErrorObject()) + end + + local ok, err = pcall(NoYield, test) + + expect(ok).to.equal(false) + expect(err:find("errored with an error object")).to.be.ok() + expect(err:find("NoYield.spec")).to.be.ok() + end) end diff --git a/src/Signal.lua b/src/Signal.lua index 28a9c42..b85300d 100644 --- a/src/Signal.lua +++ b/src/Signal.lua @@ -1,9 +1,18 @@ +--!strict --[[ A limited, simple implementation of a Signal. Handlers are fired in order, and (dis)connections are properly handled when executing an event. ]] +local __DEV__ = _G.__DEV__ + +local _, FFlagRoduxRemoveConnectTraceback = xpcall(function() + return game:DefineFastFlag("RoduxRemoveConnectTraceback", false) +end, function() + return true +end) + local function immutableAppend(list, ...) local new = {} local len = #list @@ -31,11 +40,22 @@ local function immutableRemoveValue(list, removeValue) return new end +type Listener = { + callback: (...any) -> (), + disconnected: boolean, + connectTraceback: string?, + disconnectTraceback: string?, +} + +type Store = { + _isDispatching: boolean, +} + local Signal = {} Signal.__index = Signal -function Signal.new(store) +function Signal.new(store: Store?) local self = { _listeners = {}, _store = store, @@ -59,13 +79,17 @@ function Signal:connect(callback) ) end - local listener = { + local listener: Listener = { callback = callback, disconnected = false, - connectTraceback = debug.traceback(), + connectTraceback = nil, disconnectTraceback = nil, } + if not FFlagRoduxRemoveConnectTraceback or __DEV__ then + listener.connectTraceback = debug.traceback() + end + self._listeners = immutableAppend(self._listeners, listener) local function disconnect() @@ -82,8 +106,11 @@ function Signal:connect(callback) error("You may not unsubscribe from a store listener while the reducer is executing.") end + if not FFlagRoduxRemoveConnectTraceback or __DEV__ then + listener.disconnectTraceback = debug.traceback() + end + listener.disconnected = true - listener.disconnectTraceback = debug.traceback() self._listeners = immutableRemoveValue(self._listeners, listener) end diff --git a/src/Signal.spec.lua b/src/Signal.spec.lua index e3c7823..653d0ee 100644 --- a/src/Signal.spec.lua +++ b/src/Signal.spec.lua @@ -115,7 +115,7 @@ return function() it("should throw an error if the argument to `connect` is not a function", function() local signal = Signal.new() expect(function() - signal:connect("not a function") + signal:connect("not a function" :: any) end).to.throw() end) diff --git a/src/Store.lua b/src/Store.lua index 2ce7251..daaf4d0 100644 --- a/src/Store.lua +++ b/src/Store.lua @@ -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( @@ -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, { @@ -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() @@ -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 diff --git a/src/Store.spec.lua b/src/Store.spec.lua index 8571242..0e077fc 100644 --- a/src/Store.spec.lua +++ b/src/Store.spec.lua @@ -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 @@ -169,11 +214,13 @@ return function() expect(caughtState.Value).to.equal(1) expect(caughtAction.type).to.equal("@@INIT") expect(caughtErrorResult.message).to.equal("Caught error in reducer with init") - expect(string.find(caughtErrorResult.thrownValue, innerErrorMessage)).to.be.ok() + local found = string.find(caughtErrorResult.thrownValue, innerErrorMessage) + expect(found).to.be.ok() -- We want to verify that this is a stacktrace without caring too -- much about the format, so we look for the stack frame associated -- with this test file - expect(string.find(caughtErrorResult.thrownValue, script.Name)).to.be.ok() + found = string.find(caughtErrorResult.thrownValue, script.Name) + expect(found).to.be.ok() store:destruct() end) @@ -218,11 +265,13 @@ return function() expect(caughtState.Value).to.equal(2) expect(caughtAction.type).to.equal("ThrowError") expect(caughtErrorResult.message).to.equal("Caught error in reducer") - expect(string.find(caughtErrorResult.thrownValue, innerErrorMessage)).to.be.ok() + local found = string.find(caughtErrorResult.thrownValue, innerErrorMessage) + expect(found).to.be.ok() -- We want to verify that this is a stacktrace without caring too -- much about the format, so we look for the stack frame associated -- with this test file - expect(string.find(caughtErrorResult.thrownValue, script.Name)).to.be.ok() + found = string.find(caughtErrorResult.thrownValue, script.Name) + expect(found).to.be.ok() store:destruct() end) @@ -396,14 +445,16 @@ return function() -- We want to verify that this is a stacktrace without caring too -- much about the format, so we look for the stack frame associated -- with this test file - expect(string.find(reportedErrorError, script.Name)).to.be.ok() + local found = string.find(reportedErrorError, script.Name) + expect(found).to.be.ok() -- In vanilla lua, we get this message: -- "attempt to yield across metamethod/C-call boundary" -- In luau, we should end up wrapping our own NoYield message: -- "Attempted to yield inside changed event!" -- For convenience's sake, we just look for the common substring local caughtErrorSubstring = "to yield" - expect(string.find(reportedErrorError, caughtErrorSubstring)).to.be.ok() + found = string.find(reportedErrorError, caughtErrorSubstring) + expect(found).to.be.ok() store:destruct() end) @@ -479,7 +530,8 @@ return function() -- We want to verify that this is a stacktrace without caring too -- much about the format, so we look for the stack frame associated -- with this test file - expect(string.find(caughtErrorResult.thrownValue, script.Name)).to.be.ok() + local found = string.find(caughtErrorResult.thrownValue, script.Name) + expect(found).to.be.ok() expect(caughtActionLog[1]).to.equal(actions[1]) expect(caughtActionLog[2]).to.equal(actions[2]) diff --git a/src/combineReducers.lua b/src/combineReducers.lua index f3023c4..1e807c7 100644 --- a/src/combineReducers.lua +++ b/src/combineReducers.lua @@ -1,7 +1,21 @@ +--!strict --[[ Create a composite reducer from a map of keys and sub-reducers. ]] -local function combineReducers(map) + +local actions = require(script.Parent.types.actions) +local reducers = require(script.Parent.types.reducers) +local store = require(script.Parent.types.store) + +type AnyAction = actions.AnyAction + +export type Reducer = reducers.Reducer +export type ReducersMapObject = reducers.ReducersMapObject + +type CombinedState = store.CombinedState + +local function combineReducers(map: ReducersMapObject): Reducer> + -- FIXME LUAU: Remove any cast here once we can constrain the generic type State to a table type return function(state, action) -- If state is nil, substitute it with a blank table. if state == nil then @@ -16,7 +30,7 @@ local function combineReducers(map) end return newState - end + end :: any end return combineReducers diff --git a/src/createReducer.lua b/src/createReducer.lua index 5560b59..3a7ee4c 100644 --- a/src/createReducer.lua +++ b/src/createReducer.lua @@ -1,5 +1,14 @@ -return function(initialState, handlers) - return function(state, action) +--!strict +local actions = require(script.Parent.types.actions) +local reducers = require(script.Parent.types.reducers) + +type AnyAction = actions.AnyAction + +export type Reducer = reducers.Reducer + +return function(initialState, handlers): Reducer + -- FIXME LUAU: Prefer any cast to avoid assert runtime overhead until typechecker can narrow type of _state_ to be non-nil + return function(state: any, action) if state == nil then state = initialState end diff --git a/src/init.lua b/src/init.lua index 34196e3..7ff7531 100644 --- a/src/init.lua +++ b/src/init.lua @@ -1,9 +1,27 @@ +--!strict local Store = require(script.Store) local createReducer = require(script.createReducer) local combineReducers = require(script.combineReducers) local makeActionCreator = require(script.makeActionCreator) local loggerMiddleware = require(script.loggerMiddleware) local thunkMiddleware = require(script.thunkMiddleware) +local makeThunkMiddleware = require(script.makeThunkMiddleware) + +local actions = require(script.types.actions) +local reducers = require(script.types.reducers) +local store = require(script.types.store) +local thunks = require(script.types.thunks) + +export type Action = actions.Action +export type AnyAction = actions.AnyAction +export type ActionCreator = actions.ActionCreator + +export type Reducer = reducers.Reducer + +export type Store = store.Store + +export type ThunkAction = thunks.ThunkAction +export type ThunkfulStore = thunks.ThunkfulStore return { Store = Store, @@ -12,4 +30,5 @@ return { makeActionCreator = makeActionCreator, loggerMiddleware = loggerMiddleware.middleware, thunkMiddleware = thunkMiddleware, + makeThunkMiddleware = makeThunkMiddleware, } diff --git a/src/loggerMiddleware.lua b/src/loggerMiddleware.lua index c9fddca..efe7653 100644 --- a/src/loggerMiddleware.lua +++ b/src/loggerMiddleware.lua @@ -2,9 +2,11 @@ -- module is kind of unconventional. -- -- We fix it this weird shape in init.lua. +type OutputFunction = (...any) -> () + local prettyPrint = require(script.Parent.prettyPrint) local loggerMiddleware = { - outputFunction = print, + outputFunction = (print :: any) :: OutputFunction, } function loggerMiddleware.middleware(nextDispatch, store) diff --git a/src/makeActionCreator.lua b/src/makeActionCreator.lua index e05c9f1..54017e9 100644 --- a/src/makeActionCreator.lua +++ b/src/makeActionCreator.lua @@ -1,7 +1,15 @@ ---[[ - A helper function to define a Rodux action creator with an associated name. +--!strict +--[[ + A helper function to define a Rodux action creator with an associated name. ]] -local function makeActionCreator(name, fn) + +local actions = require(script.Parent.types.actions) + +export type Action = actions.Action + +export type ActionCreator = actions.ActionCreator + +local function makeActionCreator(name: Type, fn: (Args...) -> Payload): ActionCreator assert(type(name) == "string", "Bad argument #1: Expected a string name for the action creator") assert(type(fn) == "function", "Bad argument #2: Expected a function that creates action objects") @@ -9,7 +17,7 @@ local function makeActionCreator(name, fn) return setmetatable({ name = name, }, { - __call = function(self, ...) + __call = function(_self: any, ...: Args...): Payload & Action local result = fn(...) assert(type(result) == "table", "Invalid action: An action creator must return a table") diff --git a/src/makeActionCreator.spec.lua b/src/makeActionCreator.spec.lua index a1829e8..9e191fe 100644 --- a/src/makeActionCreator.spec.lua +++ b/src/makeActionCreator.spec.lua @@ -59,11 +59,11 @@ return function() it("should throw if the second argument is not a function", function() expect(function() - makeActionCreator("foo", nil) + makeActionCreator("foo", nil :: any) end).to.throw() expect(function() - makeActionCreator("foo", {}) + makeActionCreator("foo", {} :: any) end).to.throw() end) end diff --git a/src/makeThunkMiddleware.lua b/src/makeThunkMiddleware.lua new file mode 100644 index 0000000..24c4d09 --- /dev/null +++ b/src/makeThunkMiddleware.lua @@ -0,0 +1,40 @@ +--[[ + A middleware that allows for functions to be dispatched with an extra + argument for convenience. Functions will receive two arguments: + the store itself and the extra argument provided initially to makeThunkMiddleware. + + This middleware consumes the function; middleware further down the chain + will not receive it. +]] +local function tracebackReporter(message) + return debug.traceback(message) +end + +local function makeThunkMiddleware(extraArgument) + local function thunkMiddleware(nextDispatch, store) + return function(action) + if typeof(action) == "function" then + local ok, result = xpcall(function() + return action(store, extraArgument) + end, tracebackReporter) + + if not ok then + -- report the error and move on so it's non-fatal app + store._errorReporter.reportReducerError(store:getState(), action, { + message = "Caught error in thunk", + thrownValue = result, + }) + return nil + end + + return result + end + + return nextDispatch(action) + end + end + + return thunkMiddleware +end + +return makeThunkMiddleware diff --git a/src/prettyPrint.lua b/src/prettyPrint.lua index c93d379..5dce2c3 100644 --- a/src/prettyPrint.lua +++ b/src/prettyPrint.lua @@ -1,7 +1,8 @@ +--!strict local indent = " " -local function prettyPrint(value, indentLevel) - indentLevel = indentLevel or 0 +local function prettyPrint(value, _indentLevel: number?) + local indentLevel = _indentLevel or 0 local output = {} if typeof(value) == "table" then diff --git a/src/thunkMiddleware.lua b/src/thunkMiddleware.lua index c4db036..948e354 100644 --- a/src/thunkMiddleware.lua +++ b/src/thunkMiddleware.lua @@ -1,34 +1,10 @@ +--!strict --[[ A middleware that allows for functions to be dispatched. Functions will receive a single argument, the store itself. This middleware consumes the function; middleware further down the chain will not receive it. ]] -local function tracebackReporter(message) - return debug.traceback(message) -end +local makeThunkMiddleware = require(script.Parent.makeThunkMiddleware) -local function thunkMiddleware(nextDispatch, store) - return function(action) - if typeof(action) == "function" then - local ok, result = xpcall(function() - return action(store) - end, tracebackReporter) - - if not ok then - -- report the error and move on so it's non-fatal app - store._errorReporter.reportReducerError(store:getState(), action, { - message = "Caught error in thunk", - thrownValue = result, - }) - return nil - end - - return result - end - - return nextDispatch(action) - end -end - -return thunkMiddleware +return makeThunkMiddleware(nil) -- no extra argument diff --git a/src/thunkMiddleware.spec.lua b/src/thunkMiddleware.spec.lua index b5853d5..8381d95 100644 --- a/src/thunkMiddleware.spec.lua +++ b/src/thunkMiddleware.spec.lua @@ -1,6 +1,7 @@ return function() local Store = require(script.Parent.Store) local thunkMiddleware = require(script.Parent.thunkMiddleware) + local makeThunkMiddleware = require(script.Parent.makeThunkMiddleware) it("should dispatch thunks", function() local function reducer(state, action) @@ -117,4 +118,25 @@ return function() store:dispatch(safeThunk) expect(ranSafeThunk).to.equal(true) end) + + it("should send extra argument to thunks when provided", function() + local function reducer(state, action) + return state + end + + local myExtraArg = { What = "MyExtraArg" } + local store = Store.new(reducer, {}, { makeThunkMiddleware(myExtraArg) }) + local thunkCount = 0 + local extraArgParam = nil + + local function thunk(_store, extraArg) + thunkCount = thunkCount + 1 + extraArgParam = extraArg + end + + store:dispatch(thunk) + + expect(thunkCount).to.equal(1) + expect(extraArgParam).to.equal(myExtraArg) + end) end diff --git a/src/types/actions.lua b/src/types/actions.lua new file mode 100644 index 0000000..29cd28d --- /dev/null +++ b/src/types/actions.lua @@ -0,0 +1,15 @@ +--!strict +export type Action = { + type: Type, +} + +export type AnyAction = { + [string]: any, +} & Action + +export type ActionCreator = typeof(setmetatable( + {} :: { name: Type }, + {} :: { __call: (any, Args...) -> Payload & Action } +)) + +return nil diff --git a/src/types/reducers.lua b/src/types/reducers.lua new file mode 100644 index 0000000..db8cde8 --- /dev/null +++ b/src/types/reducers.lua @@ -0,0 +1,13 @@ +--!strict +local actions = require(script.Parent.actions) + +type AnyAction = actions.AnyAction + +export type Reducer = (State?, Action) -> State + +export type ReducersMapObject = { + -- TODO Luau: used to be [K in keyof S]: K[S] + [string]: Reducer, +} + +return nil diff --git a/src/types/store.lua b/src/types/store.lua new file mode 100644 index 0000000..1f745d6 --- /dev/null +++ b/src/types/store.lua @@ -0,0 +1,29 @@ +--!strict +local actions = require(script.Parent.actions) + +type BaseAction = actions.Action + +type EmptyObject = {} + +export type CombinedState = EmptyObject & State + +export type IDispatch = (self: Store, action: Action & BaseAction) -> () +export type Dispatch = IDispatch> + +export type IStore = { + dispatch: Dispatch, + getState: (self: IStore) -> State, + + --[[ + FIXME LUAU: Typing self as any here is a hack to skirt around + variance-related issues with tables. Read-write properties + should obviate the need for this workaround. + ]] + destruct: (self: any) -> (), + flush: (self: any) -> (), + + changed: RBXScriptSignal, +} +export type Store = IStore> + +return nil diff --git a/src/types/thunks.lua b/src/types/thunks.lua new file mode 100644 index 0000000..5dca325 --- /dev/null +++ b/src/types/thunks.lua @@ -0,0 +1,18 @@ +--!strict +local store = require(script.Parent.store) + +type IStore = store.IStore +type IDispatch = store.IDispatch + +export type IThunkAction = ((store: Store) -> ReturnType) | ((store: Store) -> ()) +export type ThunkAction = IThunkAction> + +export type IThunkDispatch = ( + self: Store, + thunkAction: IThunkAction +) -> ReturnType +export type ThunkDispatch = IDispatch> & IThunkDispatch> + +export type ThunkfulStore = IStore> + +return nil diff --git a/testez.d.lua b/testez.d.lua new file mode 100644 index 0000000..349ea08 --- /dev/null +++ b/testez.d.lua @@ -0,0 +1,24 @@ +declare function afterAll(callback: () -> ()): () +declare function afterEach(callback: () -> ()): () + +declare function beforeAll(callback: () -> ()): () +declare function beforeEach(callback: () -> ()): () + +declare function describe(phrase: string, callback: () -> ()): () +declare function describeFOCUS(phrase: string, callback: () -> ()): () +declare function fdescribe(phrase: string, callback: () -> ()): () +declare function describeSKIP(phrase: string, callback: () -> ()): () +declare function xdescribe(phrase: string, callback: () -> ()): () + +declare function expect(value: any): any + +declare function FIXME(optionalMessage: string?): () +declare function FOCUS(): () +declare function SKIP(): () + +declare function it(phrase: string, callback: () -> ()): () +declare function itFOCUS(phrase: string, callback: () -> ()): () +declare function fit(phrase: string, callback: () -> ()): () +declare function itSKIP(phrase: string, callback: () -> ()): () +declare function xit(phrase: string, callback: () -> ()): () +declare function itFIXME(phrase: string, callback: () -> ()): () \ No newline at end of file diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..b3e07ec --- /dev/null +++ b/wally.toml @@ -0,0 +1,6 @@ +[package] +name = "roblox/rodux" +version = "4.0.0-rc.0" +license = "Apache-2.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared"