diff --git a/CHANGELOG.md b/CHANGELOG.md index 04061efe..4bcb6755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Roact Changelog -## Unreleased - +## Unreleased Changes * Fixed an issue where updating a host element with children to an element with `nil` children caused the old children to not be unmounted. ([#210](https://github.com/Roblox/roact/pull/210)) +* Added `Roact.joinBindings`, which allows combining multiple bindings into a single binding that can be mapped. ([#208](https://github.com/Roblox/roact/pull/208)) ## [1.0.0](https://github.com/Roblox/roact/releases/tag/v1.0.0) This release significantly reworks Roact internals to enable new features and optimizations. diff --git a/docs/api-reference.md b/docs/api-reference.md index f5ea03e0..e1488c4c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -120,6 +120,56 @@ Returns a new binding that maps the existing binding's value to something else. --- +### Roact.joinBindings +
Unreleased API
+ +``` +Roact.joinBindings(bindings) -> Binding +where + bindings: { [any]: Binding } +``` + +Combines multiple bindings into a single binding. The new binding's value will have the same keys as the input table of bindings. + +`joinBindings` is usually used alongside `Binding:map`: + +```lua +local function Flex() + local aSize, setASize = Roact.createBinding(Vector2.new()) + local bSize, setBSize = Roact.createBinding(Vector2.new()) + + return Roact.createElement("Frame", { + Size = Roact.joinBindings({aSize, bSize}):map(function(sizes) + local sum = Vector2.new() + + for _, size in ipairs(sizes) do + sum = sum + size + end + + return UDim2.new(0, sum.X, 0, sum.Y) + end), + }, { + A = Roact.createElement("Frame", { + Size = UDim2.new(1, 0, 0, 30), + [Roact.Change.AbsoluteSize] = function(instance) + setASize(instance.Size) + end, + }), + B = Roact.createElement("Frame", { + Size = UDim2.new(1, 0, 0, 30), + Position = aSize:map(function(size) + return UDim2.new(0, 0, 0, size.Y) + end), + [Roact.Change.AbsoluteSize] = function(instance) + setBSize(instance.Size) + end, + }), + }) +end +``` + +--- + ### Roact.createRef ``` Roact.createRef() -> Ref diff --git a/src/Binding.lua b/src/Binding.lua index 06a9e267..b50acf61 100644 --- a/src/Binding.lua +++ b/src/Binding.lua @@ -4,146 +4,154 @@ local Type = require(script.Parent.Type) local config = require(script.Parent.GlobalConfig).get() ---[[ - Default mapping function used for non-mapped bindings -]] -local function identity(value) - return value +local BindingImpl = Symbol.named("BindingImpl") + +local BindingInternalApi = {} + +local bindingPrototype = {} + +function bindingPrototype:getValue() + return BindingInternalApi.getValue(self) end -local Binding = {} +function bindingPrototype:map(predicate) + return BindingInternalApi.map(self, predicate) +end ---[[ - Set of keys for fields that are internal to Bindings -]] -local InternalData = Symbol.named("InternalData") +local BindingPublicMeta = { + __index = bindingPrototype, + __tostring = function(self) + return string.format("RoactBinding(%s)", tostring(self:getValue())) + end, +} -local bindingPrototype = {} -bindingPrototype.__index = bindingPrototype -bindingPrototype.__tostring = function(self) - return ("RoactBinding(%s)"):format(tostring(self[InternalData].value)) +function BindingInternalApi.update(binding, newValue) + return binding[BindingImpl].update(newValue) end ---[[ - Get the current value from a binding -]] -function bindingPrototype:getValue() - local internalData = self[InternalData] +function BindingInternalApi.subscribe(binding, callback) + return binding[BindingImpl].subscribe(callback) +end - --[[ - If our source is another binding but we're not subscribed, we'll - return the mapped value from our upstream binding. +function BindingInternalApi.getValue(binding) + return binding[BindingImpl].getValue() +end - This allows us to avoid subscribing to our source until someone - has subscribed to us, and avoid creating dangling connections. - ]] - if internalData.upstreamBinding ~= nil and internalData.upstreamDisconnect == nil then - return internalData.valueTransform(internalData.upstreamBinding:getValue()) +function BindingInternalApi.create(initialValue) + local impl = { + value = initialValue, + changeSignal = createSignal(), + } + + function impl.subscribe(callback) + return impl.changeSignal:subscribe(callback) end - return internalData.value + function impl.update(newValue) + impl.value = newValue + impl.changeSignal:fire(newValue) + end + + function impl.getValue() + return impl.value + end + + return setmetatable({ + [Type] = Type.Binding, + [BindingImpl] = impl, + }, BindingPublicMeta), impl.update end ---[[ - Creates a new binding from this one with the given mapping. -]] -function bindingPrototype:map(valueTransform) +function BindingInternalApi.map(upstreamBinding, predicate) if config.typeChecks then - assert(typeof(valueTransform) == "function", "Bad arg #1 to binding:map: expected function") + assert(Type.of(upstreamBinding) == Type.Binding, "Expected arg #1 to be a binding") + assert(typeof(predicate) == "function", "Expected arg #1 to be a function") end - local binding = Binding.create(valueTransform(self:getValue())) + local impl = {} - binding[InternalData].valueTransform = valueTransform - binding[InternalData].upstreamBinding = self - - return binding -end + function impl.subscribe(callback) + return BindingInternalApi.subscribe(upstreamBinding, function(newValue) + callback(predicate(newValue)) + end) + end ---[[ - Update a binding's value. This is only accessible by Roact. -]] -function Binding.update(binding, newValue) - local internalData = binding[InternalData] + function impl.update(newValue) + error("Bindings created by Binding:map(fn) cannot be updated directly", 2) + end - newValue = internalData.valueTransform(newValue) + function impl.getValue() + return predicate(upstreamBinding:getValue()) + end - internalData.value = newValue - internalData.changeSignal:fire(newValue) + return setmetatable({ + [Type] = Type.Binding, + [BindingImpl] = impl, + }, BindingPublicMeta) end ---[[ - Subscribe to a binding's change signal. This is only accessible by Roact. -]] -function Binding.subscribe(binding, handler) - local internalData = binding[InternalData] - - --[[ - If this binding is mapped to another and does not have any subscribers, - we need to create a subscription to our source binding so that updates - get passed along to us - ]] - if internalData.upstreamBinding ~= nil and internalData.subscriberCount == 0 then - internalData.upstreamDisconnect = Binding.subscribe(internalData.upstreamBinding, function(value) - Binding.update(binding, value) - end) +function BindingInternalApi.join(upstreamBindings) + if config.typeChecks then + assert(typeof(upstreamBindings) == "table", "Expected arg #1 to be of type table") + + for key, value in pairs(upstreamBindings) do + if Type.of(value) ~= Type.Binding then + local message = ( + "Expected arg #1 to contain only bindings, but key %q had a non-binding value" + ):format( + tostring(key) + ) + error(message, 2) + end + end end - local disconnect = internalData.changeSignal:subscribe(handler) - internalData.subscriberCount = internalData.subscriberCount + 1 + local impl = {} - local disconnected = false + local function getValue() + local value = {} - --[[ - We wrap the disconnect function so that we can manage our subscriptions - when the disconnect is triggered - ]] - return function() - if disconnected then - return + for key, upstream in pairs(upstreamBindings) do + value[key] = upstream:getValue() end - disconnected = true - disconnect() - internalData.subscriberCount = internalData.subscriberCount - 1 - - --[[ - If our subscribers count drops to 0, we can safely unsubscribe from - our source binding - ]] - if internalData.subscriberCount == 0 and internalData.upstreamDisconnect ~= nil then - internalData.upstreamDisconnect() - internalData.upstreamDisconnect = nil - end + return value end -end ---[[ - Create a new binding object with the given starting value. This - function will be exposed to users of Roact. -]] -function Binding.create(initialValue) - local binding = { - [Type] = Type.Binding, + function impl.subscribe(callback) + local disconnects = {} - [InternalData] = { - value = initialValue, - changeSignal = createSignal(), - subscriberCount = 0, + for key, upstream in pairs(upstreamBindings) do + disconnects[key] = BindingInternalApi.subscribe(upstream, function(newValue) + callback(getValue()) + end) + end - valueTransform = identity, - upstreamBinding = nil, - upstreamDisconnect = nil, - }, - } + return function() + if disconnects == nil then + return + end - setmetatable(binding, bindingPrototype) + for _, disconnect in pairs(disconnects) do + disconnect() + end - local setter = function(newValue) - Binding.update(binding, newValue) + disconnects = nil + end end - return binding, setter + function impl.update(newValue) + error("Bindings created by joinBindings(...) cannot be updated directly", 2) + end + + function impl.getValue() + return getValue() + end + + return setmetatable({ + [Type] = Type.Binding, + [BindingImpl] = impl, + }, BindingPublicMeta) end -return Binding \ No newline at end of file +return BindingInternalApi \ No newline at end of file diff --git a/src/Binding.spec.lua b/src/Binding.spec.lua index d02fff84..ee0b77a5 100644 --- a/src/Binding.spec.lua +++ b/src/Binding.spec.lua @@ -118,5 +118,143 @@ return function() expect(isEvenLengthSpy.callCount).to.equal(1) expect(lengthSpy.callCount).to.equal(1) end) + + it("should throw when updated directly", function() + local source = Binding.create(1) + local mapped = source:map(function(v) + return v + end) + + expect(function() + Binding.update(mapped, 5) + end).to.throw() + end) + end) + + describe("Binding.join", function() + it("should have getValue", function() + local binding1 = Binding.create(1) + local binding2 = Binding.create(2) + local binding3 = Binding.create(3) + + local joinedBinding = Binding.join({ + binding1, + binding2, + foo = binding3, + }) + + local bindingValue = joinedBinding:getValue() + expect(bindingValue).to.be.a("table") + expect(bindingValue[1]).to.equal(1) + expect(bindingValue[2]).to.equal(2) + expect(bindingValue.foo).to.equal(3) + end) + + it("should update when any one of the subscribed bindings updates", function() + local binding1, update1 = Binding.create(1) + local binding2, update2 = Binding.create(2) + local binding3, update3 = Binding.create(3) + + local joinedBinding = Binding.join({ + binding1, + binding2, + foo = binding3, + }) + + local spy = createSpy() + Binding.subscribe(joinedBinding, spy.value) + + expect(spy.callCount).to.equal(0) + + update1(3) + expect(spy.callCount).to.equal(1) + + local args = spy:captureValues("value") + expect(args.value).to.be.a("table") + expect(args.value[1]).to.equal(3) + expect(args.value[2]).to.equal(2) + expect(args.value["foo"]).to.equal(3) + + update2(4) + expect(spy.callCount).to.equal(2) + + args = spy:captureValues("value") + expect(args.value).to.be.a("table") + expect(args.value[1]).to.equal(3) + expect(args.value[2]).to.equal(4) + expect(args.value["foo"]).to.equal(3) + + update3(8) + expect(spy.callCount).to.equal(3) + + args = spy:captureValues("value") + expect(args.value).to.be.a("table") + expect(args.value[1]).to.equal(3) + expect(args.value[2]).to.equal(4) + expect(args.value["foo"]).to.equal(8) + end) + + it("should disconnect from all upstream bindings", function() + local binding1, update1 = Binding.create(1) + local binding2, update2 = Binding.create(2) + + local joined = Binding.join({binding1, binding2}) + + local spy = createSpy() + local disconnect = Binding.subscribe(joined, spy.value) + + expect(spy.callCount).to.equal(0) + + update1(3) + expect(spy.callCount).to.equal(1) + + update2(3) + expect(spy.callCount).to.equal(2) + + disconnect() + update1(4) + expect(spy.callCount).to.equal(2) + + update2(2) + expect(spy.callCount).to.equal(2) + + local value = joined:getValue() + expect(value[1]).to.equal(4) + expect(value[2]).to.equal(2) + end) + + it("should be okay with calling disconnect multiple times", function() + local joined = Binding.join({}) + + local disconnect = Binding.subscribe(joined, function() end) + + disconnect() + disconnect() + end) + + it("should throw if updated directly", function() + local joined = Binding.join({}) + + expect(function() + Binding.update(joined, 0) + end) + end) + + it("should throw when a non-table value is passed", function() + expect(function() + Binding.join("hi") + end).to.throw() + end) + + it("should throw when a non-binding value is passed via table", function() + expect(function() + local binding = Binding.create(123) + + Binding.join({ + binding, + "abcde", + }) + end).to.throw() + end) end) -end +end \ No newline at end of file diff --git a/src/init.lua b/src/init.lua index cbaa6f0a..f002f975 100644 --- a/src/init.lua +++ b/src/init.lua @@ -22,6 +22,7 @@ local Roact = strict { Portal = require(script.Portal), createRef = require(script.createRef), createBinding = Binding.create, + joinBindings = Binding.join, Change = require(script.PropMarkers.Change), Children = require(script.PropMarkers.Children), diff --git a/src/init.spec.lua b/src/init.spec.lua index 7b23a173..7fcf79c8 100644 --- a/src/init.spec.lua +++ b/src/init.spec.lua @@ -7,6 +7,7 @@ return function() createFragment = "function", createRef = "function", createBinding = "function", + joinBindings = "function", mount = "function", unmount = "function", update = "function",