diff --git a/src/__tests__/batch.spec.luau b/src/__tests__/batch.spec.luau new file mode 100644 index 0000000..b83d002 --- /dev/null +++ b/src/__tests__/batch.spec.luau @@ -0,0 +1,38 @@ +local atom = require(script.Parent.Parent.atom) +local batch = require(script.Parent.Parent.batch) +local effect = require(script.Parent.Parent.effect) +local subscribe = require(script.Parent.Parent.subscribe) + +return function() + it("batches updates", function() + local source = atom(1) + local value = 0 + subscribe(source, function() + value += 1 + end) + batch(function() + source(2) + source(3) + source(4) + end) + expect(value).to.equal(1) + end) + + it("handles duplicate listeners", function() + local a = atom(1) + local b = atom(1) + local value = 0 + effect(function() + value += 1 + a() + b() + end) + value = 0 -- effect() will run once initially + batch(function() + a(2) + b(2) + a(3) + end) + expect(value).to.equal(1) + end) +end diff --git a/src/atom.luau b/src/atom.luau index b39cd97..e6b659a 100644 --- a/src/atom.luau +++ b/src/atom.luau @@ -1,35 +1,33 @@ local store = require(script.Parent.store) local types = require(script.Parent.types) -local unwrap = require(script.Parent.unwrap) type Atom = types.Atom type AtomOptions = types.AtomOptions +local unwrap = require(script.Parent.unwrap) local function atom(state: T, options: AtomOptions?): Atom local equals = options and options.equals local function atom(...) if select("#", ...) == 0 then - if store.shouldCapture() then + if store.isCapturing() then store.captured[atom] = true end return state end - local nextState = ... - - if type(nextState) == "function" then - -- Atoms called in a function should not be included as dependencies - nextState = unwrap(function() - return nextState(state) - end) - end + -- Atoms called in a function should not be a part of the dependency tree + local nextState = unwrap(..., state) if state ~= nextState and not (equals and equals(state, nextState)) then state = nextState - for listener in pairs(table.clone(store.listeners[atom])) do - listener() + if store.isBatching() then + store.batched[atom] = true + else + for listener in pairs(table.clone(store.listeners[atom])) do + listener() + end end end diff --git a/src/batch.luau b/src/batch.luau new file mode 100644 index 0000000..b7d6a4e --- /dev/null +++ b/src/batch.luau @@ -0,0 +1,26 @@ +local store = require(script.Parent.store) + +local function batch(callback: () -> ()) + if store.isBatching() then + return callback() + end + + local visited: { [() -> ()]: true } = {} + + store.setBatching(true) + callback() + store.setBatching(false) + + for atom in store.batched do + for listener in pairs(store.listeners[atom]) do + if not visited[listener] then + visited[listener] = true + listener() + end + end + end + + table.clear(store.batched) +end + +return batch diff --git a/src/capture.luau b/src/capture.luau index 9526de9..e105e54 100644 --- a/src/capture.luau +++ b/src/capture.luau @@ -1,24 +1,19 @@ local store = require(script.Parent.store) local types = require(script.Parent.types) type Atom = types.Atom -type Molecule = types.Molecule -local function capture(molecule: Molecule): ({ [Atom]: true }, T) - if store.listeners[molecule] then - return { [molecule] = true }, molecule() +local function capture(callback: () -> T): ({ [Atom]: true }, T) + if store.listeners[callback] then + return { [callback] = true }, callback() end store.setCapturing(true) - local success, result = pcall(molecule) + local result = callback() store.setCapturing(false) local dependencies = table.clone(store.captured) table.clear(store.captured) - if not success then - warn("An error occurred while calling an atom:", result) - end - return dependencies, result end diff --git a/src/index.d.ts b/src/index.d.ts index 03ae5c0..5cfd38d 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -28,12 +28,14 @@ declare namespace Charm { function subscribe(molecule: Molecule, callback: (state: State, prev: State) => void): Cleanup; - function effect(callback: Molecule): Cleanup; + function effect(callback: () => void): Cleanup; - function unwrap(molecule: State | Molecule): State; + function unwrap(molecule: State | ((...args: Args) => State), ...args: Args): State; function capture(molecule: Molecule): LuaTuple<[captured: Set>, state: State]>; + function batch(callback: () => void): void; + function observe( molecule: Molecule, factory: (item: Item, index: number) => Cleanup | void, diff --git a/src/init.luau b/src/init.luau index 0464998..5c10a89 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,6 +1,7 @@ _G[script.modules.Promise] = _G[script] local atom = require(script.atom) +local batch = require(script.batch) local capture = require(script.capture) local derive = require(script.derive) local effect = require(script.effect) @@ -21,6 +22,7 @@ export type SyncPayload = types.SyncPayload return { atom = atom, + batch = batch, capture = capture, derive = derive, effect = effect, diff --git a/src/modules/ReactRoblox.luau b/src/modules/ReactRoblox.luau index eeed498..9c41f68 100644 --- a/src/modules/ReactRoblox.luau +++ b/src/modules/ReactRoblox.luau @@ -5,4 +5,5 @@ local Packages = if Package.Parent:FindFirstChild("ReactRoblox") type ReactRoblox = typeof(require(Package.Parent.ReactRoblox)) +-- [INFO] It's safe to require this module since it's only used in unit tests. return (if Packages then require(Packages.ReactRoblox) else nil) :: ReactRoblox diff --git a/src/react/useAtom.luau b/src/react/useAtom.luau index c2f9f29..3dad656 100644 --- a/src/react/useAtom.luau +++ b/src/react/useAtom.luau @@ -1,6 +1,5 @@ local types = require(script.Parent.Parent.types) type Atom = types.Atom - local useAtomState = require(script.Parent.useAtomState) local useSetAtom = require(script.Parent.useSetAtom) diff --git a/src/react/useAtomState.luau b/src/react/useAtomState.luau index b6da1ee..dbac9c3 100644 --- a/src/react/useAtomState.luau +++ b/src/react/useAtomState.luau @@ -1,6 +1,5 @@ local requireReact = require(script.Parent.Parent.modules.React) local subscribe = require(script.Parent.Parent.subscribe) - local types = require(script.Parent.Parent.types) type Atom = types.Atom diff --git a/src/react/useSetAtom.luau b/src/react/useSetAtom.luau index 762befb..3c6c370 100644 --- a/src/react/useSetAtom.luau +++ b/src/react/useSetAtom.luau @@ -1,5 +1,4 @@ local requireReact = require(script.Parent.Parent.modules.React) - local types = require(script.Parent.Parent.types) type Atom = types.Atom diff --git a/src/store.luau b/src/store.luau index d8b354b..1484c29 100644 --- a/src/store.luau +++ b/src/store.luau @@ -4,19 +4,32 @@ type WeakMap = types.WeakMap local listeners: WeakMap, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) local captured: { [Atom]: true } = {} +local batched: { [Atom]: true } = {} local capturing = false +local batching = false local function setCapturing(value: boolean) capturing = value end -local function shouldCapture() +local function isCapturing() return capturing end +local function setBatching(value: boolean) + batching = value +end + +local function isBatching() + return batching +end + return { listeners = listeners, captured = captured, + batched = batched, setCapturing = setCapturing, - shouldCapture = shouldCapture, + isCapturing = isCapturing, + setBatching = setBatching, + isBatching = isBatching, } diff --git a/src/sync/patch.luau b/src/sync/patch.luau index 661877e..6af6034 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -2,8 +2,6 @@ local validate = require(script.Parent.validate) local NONE = { __none = "__none" } -local warnings = {} - local function diff(prevState: { [any]: any }, nextState: { [any]: any }) local patches = table.clone(nextState) @@ -20,20 +18,14 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }) end if _G.__DEV__ then - local success, result = pcall(function() - for key, value in prevState do + for key, value in prevState do + validate(value, key) + end + + for key, value in nextState do + if prevState[key] ~= value then validate(value, key) end - for key, value in nextState do - if prevState[key] ~= value then - validate(value, key) - end - end - end) - - if not success and not warnings[result] then - warnings[result] = true - warn(result) end end diff --git a/src/unwrap.luau b/src/unwrap.luau index 390188e..63c6b52 100644 --- a/src/unwrap.luau +++ b/src/unwrap.luau @@ -1,22 +1,18 @@ local store = require(script.Parent.store) -local function unwrap(atom: (() -> T) | T): T - if type(atom) ~= "function" then - return atom +local function unwrap(callback: ((U...) -> T) | T, ...: U...): T + if type(callback) ~= "function" then + return callback end - if not store.shouldCapture() then - return atom() + if not store.isCapturing() then + return callback(...) end store.setCapturing(false) - local success, result = pcall(atom) + local result = callback(...) store.setCapturing(true) - if not success then - error(`Failed to unwrap atom: ${result}`, 2) - end - return result end