From d9b7f9661b26ff16db240f2fe8b0f8284303c61d Mon Sep 17 00:00:00 2001 From: Patrick Sewell Date: Tue, 21 Jan 2020 09:48:50 -0800 Subject: [PATCH] Add Context API (#246) * Added Context api Added createContext, provide, and consume APIs to Roact. * Remove provide and consume Removes provide and consume from the new Roact context API, so that createContext returns a Provider and Consumer. * Update from Code Review comments -Use fragment instead of oneChild -Use spies for testing -Use Component over PureComponent -Check for duplicates in didUpdate -nil check disconnect * Update to use new Internal Context API Updates the createContext API to internally use the new internal Context API. --- CHANGELOG.md | 1 + src/createContext.lua | 101 ++++++++++++++++++++++++++++++ src/createContext.spec.lua | 122 +++++++++++++++++++++++++++++++++++++ src/init.lua | 1 + src/init.spec.lua | 1 + 5 files changed, 226 insertions(+) create mode 100644 src/createContext.lua create mode 100644 src/createContext.spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 76191aaa..e2bdfc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Roact Changelog ## Unreleased Changes +* Added Contexts, which enables easy handling of items that are provided and consumed throughout the tree. ## [1.2.0](https://github.com/Roblox/roact/releases/tag/v1.2.0) (September 6th, 2019) * Fixed a bug where derived state was lost when assigning directly to state in init ([#232](https://github.com/Roblox/roact/pull/232/)) diff --git a/src/createContext.lua b/src/createContext.lua new file mode 100644 index 00000000..260f8a4f --- /dev/null +++ b/src/createContext.lua @@ -0,0 +1,101 @@ +local Symbol = require(script.Parent.Symbol) +local Binding = require(script.Parent.Binding) +local createFragment = require(script.Parent.createFragment) +local Children = require(script.Parent.PropMarkers.Children) +local Component = require(script.Parent.Component) + +local function createProvider(context) + local Provider = Component:extend("Provider") + + function Provider:init(props) + self.binding, self.updateValue = Binding.create(props.value) + + local key = context.key + self:__addContext(key, self.binding) + end + + function Provider:didUpdate(prevProps) + if prevProps.value ~= self.props.value then + self.updateValue(self.props.value) + end + end + + function Provider:render() + return createFragment(self.props[Children]) + end + + return Provider +end + +local function createConsumer(context) + local Consumer = Component:extend("Consumer") + + function Consumer:init(props) + local key = context.key + local binding = self:__getContext(key) + + if binding ~= nil then + self.state = { + value = binding:getValue(), + } + + -- Update if the Context updated + self.disconnect = Binding.subscribe(binding, function() + self:setState({ + value = binding:getValue(), + }) + end) + else + -- Fall back to the default value if no Provider exists + self.state = { + value = context.defaultValue, + } + end + end + + function Consumer.validateProps(props) + if type(props.render) ~= "function" then + return false, "Consumer expects a `render` function" + else + return true + end + end + + function Consumer:render() + return self.props.render(self.state.value) + end + + function Consumer:willUnmount() + if self.disconnect ~= nil then + self.disconnect() + end + end + + return Consumer +end + +local Context = {} +Context.__index = Context + +function Context.new(defaultValue) + local self = { + defaultValue = defaultValue, + key = Symbol.named("ContextKey"), + } + setmetatable(self, Context) + return self +end + +function Context:__tostring() + return "RoactContext" +end + +local function createContext(defaultValue) + local context = Context.new(defaultValue) + return { + Provider = createProvider(context), + Consumer = createConsumer(context), + } +end + +return createContext diff --git a/src/createContext.spec.lua b/src/createContext.spec.lua new file mode 100644 index 00000000..ad365492 --- /dev/null +++ b/src/createContext.spec.lua @@ -0,0 +1,122 @@ +return function() + local createContext = require(script.Parent.createContext) + local createElement = require(script.Parent.createElement) + local NoopRenderer = require(script.Parent.NoopRenderer) + local createReconciler = require(script.Parent.createReconciler) + local createSpy = require(script.Parent.createSpy) + + local noopReconciler = createReconciler(NoopRenderer) + + it("should return a table", function() + local context = createContext("Test") + expect(context).to.be.ok() + expect(type(context)).to.equal("table") + end) + + it("should contain a Provider and a Consumer", function() + local context = createContext("Test") + expect(context.Provider).to.be.ok() + expect(context.Consumer).to.be.ok() + end) + + describe("Provider", function() + it("should render its children", function() + local context = createContext("Test") + + local Listener = createSpy(function() + return nil + end) + + local element = createElement(context.Provider, { + value = "Test", + }, { + Listener = createElement(Listener.value), + }) + + local tree = noopReconciler.mountVirtualTree(element, nil, "Provide Tree") + noopReconciler.unmountVirtualTree(tree) + + expect(Listener.callCount).to.equal(1) + end) + end) + + describe("Consumer", function() + it("should expect a render function", function() + local context = createContext("Test") + local element = createElement(context.Consumer) + + expect(function() + noopReconciler.mountVirtualTree(element, nil, "Provide Tree") + end).to.throw() + end) + + it("should return the default value if there is no Provider", function() + local valueSpy = createSpy() + local context = createContext("Test") + + local element = createElement(context.Consumer, { + render = valueSpy.value, + }) + + local tree = noopReconciler.mountVirtualTree(element, nil, "Provide Tree") + noopReconciler.unmountVirtualTree(tree) + + valueSpy:assertCalledWith("Test") + end) + + it("should pass the value to the render function", function() + local valueSpy = createSpy() + local context = createContext("Test") + + local function Listener() + return createElement(context.Consumer, { + render = valueSpy.value, + }) + end + + local element = createElement(context.Provider, { + value = "NewTest", + }, { + Listener = createElement(Listener), + }) + + local tree = noopReconciler.mountVirtualTree(element, nil, "Provide Tree") + noopReconciler.unmountVirtualTree(tree) + + valueSpy:assertCalledWith("NewTest") + end) + + it("should update when the value updates", function() + local valueSpy = createSpy() + local context = createContext("Test") + + local function Listener() + return createElement(context.Consumer, { + render = valueSpy.value, + }) + end + + local element = createElement(context.Provider, { + value = "NewTest", + }, { + Listener = createElement(Listener), + }) + + local tree = noopReconciler.mountVirtualTree(element, nil, "Provide Tree") + + expect(valueSpy.callCount).to.equal(1) + valueSpy:assertCalledWith("NewTest") + + noopReconciler.updateVirtualTree(tree, createElement(context.Provider, { + value = "ThirdTest", + }, { + Listener = createElement(Listener), + })) + + expect(valueSpy.callCount).to.equal(3) + valueSpy:assertCalledWith("ThirdTest") + + noopReconciler.unmountVirtualTree(tree) + end) + end) +end diff --git a/src/init.lua b/src/init.lua index f002f975..0bac3010 100644 --- a/src/init.lua +++ b/src/init.lua @@ -23,6 +23,7 @@ local Roact = strict { createRef = require(script.createRef), createBinding = Binding.create, joinBindings = Binding.join, + createContext = require(script.createContext), Change = require(script.PropMarkers.Change), Children = require(script.PropMarkers.Children), diff --git a/src/init.spec.lua b/src/init.spec.lua index 7fcf79c8..652ee19a 100644 --- a/src/init.spec.lua +++ b/src/init.spec.lua @@ -13,6 +13,7 @@ return function() update = "function", oneChild = "function", setGlobalConfig = "function", + createContext = "function", -- These functions are deprecated and throw warnings! reify = "function",