From e48b2b2efe4f28e1aadd9aa9f19e9de65173e58f Mon Sep 17 00:00:00 2001 From: Lucien Greathouse <me@lpghatguy.com> Date: Tue, 1 May 2018 14:56:25 -0700 Subject: [PATCH 1/7] Generalize mergeState function, implement defaultProps --- lib/Component.lua | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/Component.lua b/lib/Component.lua index 1a7d3cef..a058987d 100644 --- a/lib/Component.lua +++ b/lib/Component.lua @@ -18,22 +18,29 @@ local tick = tick Component.__index = Component -local function mergeState(currentState, partialState) - local newState = {} +--[[ + Merge any number of dictionaries into a new dictionary, overwriting keys. - for key, value in pairs(currentState) do - newState[key] = value - end + If a value of `Core.None` is encountered, the key will be removed instead. + This is necessary because Lua doesn't differentiate between a key being + missing and a key being set to nil. +]] +local function merge(...) + local result = {} - for key, value in pairs(partialState) do - if value == Core.None then - newState[key] = nil - else - newState[key] = value + for i = 1, select("#", ...) do + local entry = select(i, ...) + + for key, value in pairs(entry) do + if value == Core.None then + result[key] = nil + else + result[key] = value + end end end - return newState + return result end --[[ @@ -73,7 +80,12 @@ function Component:extend(name) -- You can see a list of reasons in invalidSetStateMessages. self._setStateBlockedReason = nil - self.props = props + if class.defaultProps == nil then + self.props = props + else + self.props = merge(class.defaultProps, props) + end + self._context = {} -- Shallow copy all context values from our parent element. @@ -101,7 +113,7 @@ function Component:extend(name) local partialState = class.getDerivedStateFromProps(props, self.state) if partialState then - self.state = mergeState(self.state, partialState) + self.state = merge(self.state, partialState) end end @@ -174,7 +186,7 @@ function Component:setState(partialState) end end - local newState = mergeState(self.state, partialState) + local newState = merge(self.state, partialState) self:_update(self.props, newState) end @@ -224,7 +236,7 @@ function Component:_forceUpdate(newProps, newState) -- getDerivedStateFromProps can return nil if no changes are necessary. if derivedState ~= nil then - newState = mergeState(newState or self.state, derivedState) + newState = merge(newState or self.state, derivedState) end end end From f3d229eb7ef3c073b32ce53210b2726c4f1091cb Mon Sep 17 00:00:00 2001 From: Lucien Greathouse <me@lpghatguy.com> Date: Tue, 1 May 2018 16:55:30 -0700 Subject: [PATCH 2/7] Implement defaultProps test --- lib/Component.spec.lua | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/lib/Component.spec.lua b/lib/Component.spec.lua index a596562a..8eda4369 100644 --- a/lib/Component.spec.lua +++ b/lib/Component.spec.lua @@ -216,6 +216,40 @@ return function() Reconciler.teardown(handle) end) + it("should pull values from defaultProps where appropriate", function() + local lastProps + local TestComponent = Component:extend("TestComponent") + + TestComponent.defaultProps = { + foo = "hello", + bar = "world", + } + + function TestComponent:render() + lastProps = self.props + return nil + end + + local handle = Reconciler.reify(Core.createElement(TestComponent)) + + expect(lastProps).to.be.a("table") + expect(lastProps.foo).to.equal("hello") + expect(lastProps.bar).to.equal("world") + + Reconciler.teardown(handle) + + lastProps = nil + handle = Reconciler.reify(Core.createElement(TestComponent, { + foo = 5, + })) + + expect(lastProps).to.be.a("table") + expect(lastProps.foo).to.equal(5) + expect(lastProps.bar).to.equal("world") + + Reconciler.teardown(handle) + end) + describe("setState", function() it("should throw when called in init", function() local InitComponent = Component:extend("InitComponent") From 39af342f61019fc707b98a314000e837fe8950d1 Mon Sep 17 00:00:00 2001 From: Lucien Greathouse <me@lpghatguy.com> Date: Tue, 1 May 2018 16:57:21 -0700 Subject: [PATCH 3/7] Add additional edge case of 'false' value --- lib/Component.spec.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/Component.spec.lua b/lib/Component.spec.lua index 8eda4369..232ddcbe 100644 --- a/lib/Component.spec.lua +++ b/lib/Component.spec.lua @@ -248,6 +248,17 @@ return function() expect(lastProps.bar).to.equal("world") Reconciler.teardown(handle) + + lastProps = nil + handle = Reconciler.reify(Core.createElement(TestComponent, { + bar = false, + })) + + expect(lastProps).to.be.a("table") + expect(lastProps.foo).to.equal("hello") + expect(lastProps.bar).to.equal(false) + + Reconciler.teardown(handle) end) describe("setState", function() From d0aa46dccdd88e95f3dabdc76399384bb728ed4d Mon Sep 17 00:00:00 2001 From: Lucien Greathouse <me@lpghatguy.com> Date: Tue, 1 May 2018 17:00:56 -0700 Subject: [PATCH 4/7] Add entry to CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e1c2ea..82111d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added `Roact.Change` for subscribing to `GetPropertyChangedSignal` ([#51](https://github.com/Roblox/roact/pull/51)) * Added the static lifecycle method `getDerivedStateFromProps` ([#57](https://github.com/Roblox/roact/pull/57)) * Allow canceling render by returning nil from setState callback ([#64](https://github.com/Roblox/roact/pull/64)) +* Added `defaultProps` value on stateful components to define values for props that aren't specified ([#79](https://github.com/Roblox/roact/pull/79)) ## 1.0.0 Prerelease 2 (March 22, 2018) * Removed `is*Element` methods, this is unlikely to affect anyone ([#50](https://github.com/Roblox/roact/pull/50)) From 3b16a30b9bfc470322165c2e6f5e4a85e6bb6cb6 Mon Sep 17 00:00:00 2001 From: Lucien Greathouse <me@lpghatguy.com> Date: Thu, 3 May 2018 12:24:25 -0700 Subject: [PATCH 5/7] Fall back to defaultProps if an update causes a prop to transition to nil --- lib/Component.lua | 19 ++++++++++++++++++- lib/Component.spec.lua | 31 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lib/Component.lua b/lib/Component.lua index b0d5af87..7f8f6c90 100644 --- a/lib/Component.lua +++ b/lib/Component.lua @@ -229,7 +229,7 @@ function Component:_forceUpdate(newProps, newState) -- Get the class - getDerivedStateFromProps is static. local class = getmetatable(self) - -- Only update if newProps are given! + -- If newProps are passed, compute derived state and default props if newProps then if class.getDerivedStateFromProps then local derivedState = class.getDerivedStateFromProps(newProps, newState or self.state) @@ -239,6 +239,23 @@ function Component:_forceUpdate(newProps, newState) newState = merge(newState or self.state, derivedState) end end + + if class.defaultProps then + -- We only allocate another prop table if there are props that are + -- falling back to their default. + local replacementProps + + for key in pairs(class.defaultProps) do + if newProps[key] == nil then + replacementProps = merge(class.defaultProps, newProps) + break + end + end + + if replacementProps then + newProps = replacementProps + end + end end if self.willUpdate then diff --git a/lib/Component.spec.lua b/lib/Component.spec.lua index 232ddcbe..9aafe893 100644 --- a/lib/Component.spec.lua +++ b/lib/Component.spec.lua @@ -261,6 +261,37 @@ return function() Reconciler.teardown(handle) end) + it("should fall back to correctly defaultProps after an update", function() + local lastProps + local TestComponent = Component:extend("TestComponent") + + TestComponent.defaultProps = { + foo = "hello", + bar = "world", + } + + function TestComponent:render() + lastProps = self.props + return nil + end + + local handle = Reconciler.reify(Core.createElement(TestComponent, { + foo = "hey" + })) + + expect(lastProps).to.be.a("table") + expect(lastProps.foo).to.equal("hey") + expect(lastProps.bar).to.equal("world") + + handle = Reconciler.reconcile(handle, Core.createElement(TestComponent)) + + expect(lastProps).to.be.a("table") + expect(lastProps.foo).to.equal("hello") + expect(lastProps.bar).to.equal("world") + + Reconciler.teardown(handle) + end) + describe("setState", function() it("should throw when called in init", function() local InitComponent = Component:extend("InitComponent") From b5e4241c4e4c767097c5009172268b2f5def3006 Mon Sep 17 00:00:00 2001 From: Lucien Greathouse <me@lpghatguy.com> Date: Thu, 3 May 2018 12:24:53 -0700 Subject: [PATCH 6/7] Add light documentation to the API reference I'll probably expand on this more when propTypes are implemented, since that's where defaultProps starts to shine the brightest. --- docs/api-reference.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/api-reference.md b/docs/api-reference.md index c8593a32..191b2dc7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -138,6 +138,13 @@ See [the Portals guide](/advanced/portals.md) for a small tutorial and more deta ## Component API +### defaultProps +``` +static defaultProps: Dictionary<any, any> +``` + +If `defaultProps` is defined on a stateful component, any props that aren't specified when a component is created will be taken from there. + ### init ``` init(initialProps) -> void From 449e952ad89fd5dc68cb209ee5187d97bd063266 Mon Sep 17 00:00:00 2001 From: Lucien Greathouse <me@lpghatguy.com> Date: Thu, 3 May 2018 12:28:18 -0700 Subject: [PATCH 7/7] Fix grammatical typo in spec description --- lib/Component.spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Component.spec.lua b/lib/Component.spec.lua index 9aafe893..69e51f46 100644 --- a/lib/Component.spec.lua +++ b/lib/Component.spec.lua @@ -261,7 +261,7 @@ return function() Reconciler.teardown(handle) end) - it("should fall back to correctly defaultProps after an update", function() + it("should fall back to defaultProps correctly after an update", function() local lastProps local TestComponent = Component:extend("TestComponent")