From b25a06343b7bcbe56b87026bb9ad96dc49951322 Mon Sep 17 00:00:00 2001 From: AmaranthineCodices <31936135+AmaranthineCodices@users.noreply.github.com> Date: Wed, 16 May 2018 18:29:55 -0400 Subject: [PATCH] createRef API (#92) --- CHANGELOG.md | 1 + docs/api-reference.md | 31 ++++++++++++++++ lib/Reconciler.lua | 32 +++++++++++------ lib/Reconciler.spec.lua | 78 +++++++++++++++++++++++++++++++++++++++++ lib/createRef.lua | 20 +++++++++++ lib/createRef.spec.lua | 15 ++++++++ lib/init.lua | 2 ++ lib/init.spec.lua | 1 + 8 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 lib/createRef.lua create mode 100644 lib/createRef.spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 86b53dd9..572456c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * 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)) +* Added `createRef` ([#70](https://github.com/Roblox/roact/issues/70), [#92](https://github.com/Roblox/roact/pull/92)) ## 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)) diff --git a/docs/api-reference.md b/docs/api-reference.md index dba370f2..748dd9c2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -61,6 +61,13 @@ If `children` contains more than one child, `oneChild` function will throw an er If `children` is `nil` or contains no children, `oneChild` will return `nil`. +### Roact.createRef +``` +Roact.createRef() -> Ref +``` + +Creates a new reference object that can be used with [Roact.Ref](#roactref). + ## Constants ### Roact.Children @@ -71,14 +78,38 @@ If you're writing a new functional or stateful element that needs to be used lik ### Roact.Ref Use `Roact.Ref` as a key into the props of a primitive element to receive a handle to the underlying Roblox Instance. +`Ref` may either be a function: ```lua Roact.createElement("Frame", { + -- The function given will be called whenever the rendered instance changes. [Roact.Ref] = function(rbx) print("Roblox Instance", rbx) end, }) ``` +Or a reference object created with [createRef](#roactcreateref): +```lua +local ExampleComponent = Roact.Component:extend("ExampleComponent") + +function ExampleComponent:init() + -- Create a reference object. + self.ref = Roact.createRef() +end + +function ExampleComponent:render() + return Roact.createElement("Frame", { + -- Use the reference object to point to this rendered instance. + [Roact.Ref] = ref, + }) +end + +function ExampleComponent:didMount() + -- Access the current value of a reference object using its current property. + print("Roblox Instance", self.ref.current) +end +``` + !!! warning `Roact.Ref` will be called with `nil` when the component instance is destroyed! diff --git a/lib/Reconciler.lua b/lib/Reconciler.lua index 3b758c60..37aa2430 100644 --- a/lib/Reconciler.lua +++ b/lib/Reconciler.lua @@ -44,6 +44,22 @@ local function isPortal(element) return element.component == Core.Portal end +--[[ + Sets the value of a reference to a new rendered object. + Correctly handles both function-style and object-style refs. +]] +local function applyRef(ref, newRbx) + if ref == nil then + return + end + + if type(ref) == "table" then + ref.current = newRbx + else + ref(newRbx) + end +end + local Reconciler = {} Reconciler._singleEventManager = SingleEventManager.new() @@ -93,9 +109,7 @@ function Reconciler.unmount(instanceHandle) -- Kill refs before we make changes, since any mutations past this point -- aren't relevant to components. - if element.props[Core.Ref] then - element.props[Core.Ref](nil) - end + applyRef(element.props[Core.Ref], nil) for _, child in pairs(instanceHandle._children) do Reconciler.unmount(child) @@ -173,9 +187,7 @@ function Reconciler._mountInternal(element, parent, key, context) rbx.Parent = parent -- Attach ref values, since the instance is initialized now. - if element.props[Core.Ref] then - element.props[Core.Ref](rbx) - end + applyRef(element.props[Core.Ref], rbx) return { [isInstanceHandle] = true, @@ -317,13 +329,13 @@ function Reconciler._reconcileInternal(instanceHandle, newElement) if isPrimitiveElement(newElement) then -- Roblox Instance change - local oldRef = oldElement[Core.Ref] - local newRef = newElement[Core.Ref] + local oldRef = oldElement.props[Core.Ref] + local newRef = newElement.props[Core.Ref] local refChanged = (oldRef ~= newRef) -- Cancel the old ref before we make changes. Apply the new one after. if refChanged and oldRef then - oldRef(nil) + applyRef(oldRef, nil) end -- Update properties and children of the Roblox object. @@ -334,7 +346,7 @@ function Reconciler._reconcileInternal(instanceHandle, newElement) -- Apply the new ref if there was a ref change. if refChanged and newRef then - newRef(instanceHandle._rbx) + applyRef(newRef, instanceHandle._rbx) end return instanceHandle diff --git a/lib/Reconciler.spec.lua b/lib/Reconciler.spec.lua index 29a76b41..4dbfa597 100644 --- a/lib/Reconciler.spec.lua +++ b/lib/Reconciler.spec.lua @@ -1,8 +1,86 @@ return function() + local Core = require(script.Parent.Core) local Reconciler = require(script.Parent.Reconciler) + local createRef = require(script.Parent.createRef) it("should mount booleans as nil", function() local booleanReified = Reconciler.mount(false) expect(booleanReified).to.never.be.ok() end) + + it("should handle object references properly", function() + local objectRef = createRef() + local element = Core.createElement("StringValue", { + [Core.Ref] = objectRef, + }) + + local handle = Reconciler.mount(element) + expect(objectRef.current).to.be.ok() + Reconciler.unmount(handle) + expect(objectRef.current).to.never.be.ok() + end) + + it("should handle function references properly", function() + local currentRbx + + local function ref(rbx) + currentRbx = rbx + end + + local element = Core.createElement("StringValue", { + [Core.Ref] = ref, + }) + + local handle = Reconciler.mount(element) + expect(currentRbx).to.be.ok() + Reconciler.unmount(handle) + expect(currentRbx).to.never.be.ok() + end) + + it("should handle changing function references", function() + local aValue, bValue + + local function aRef(rbx) + aValue = rbx + end + + local function bRef(rbx) + bValue = rbx + end + + local element = Core.createElement("StringValue", { + [Core.Ref] = aRef, + }) + + local handle = Reconciler.mount(element, game, "Test123") + expect(aValue).to.be.ok() + expect(bValue).to.never.be.ok() + handle = Reconciler.reconcile(handle, Core.createElement("StringValue", { + [Core.Ref] = bRef, + })) + expect(aValue).to.never.be.ok() + expect(bValue).to.be.ok() + Reconciler.unmount(handle) + expect(bValue).to.never.be.ok() + end) + + it("should handle changing object references", function() + local aRef = createRef() + local bRef = createRef() + + local element = Core.createElement("StringValue", { + [Core.Ref] = aRef, + }) + + local handle = Reconciler.mount(element, game, "Test123") + expect(aRef.current).to.be.ok() + expect(bRef.current).to.never.be.ok() + handle = Reconciler.reconcile(handle, Core.createElement("StringValue", { + [Core.Ref] = bRef, + })) + expect(aRef.current).to.never.be.ok() + expect(bRef.current).to.be.ok() + Reconciler.unmount(handle) + expect(bRef.current).to.never.be.ok() + end) end \ No newline at end of file diff --git a/lib/createRef.lua b/lib/createRef.lua new file mode 100644 index 00000000..bb3ae0bb --- /dev/null +++ b/lib/createRef.lua @@ -0,0 +1,20 @@ +--[[ + Provides an API for acquiring a reference to a reified object. This + API is designed to mimic React 16.3's createRef API. + + See: + * https://reactjs.org/docs/refs-and-the-dom.html + * https://reactjs.org/blog/2018/03/29/react-v-16-3.html#createref-api +]] + +local refMetatable = { + __tostring = function(self) + return ("RoactReference(%s)"):format(tostring(self.current)) + end, +} + +return function() + return setmetatable({ + current = nil, + }, refMetatable) +end \ No newline at end of file diff --git a/lib/createRef.spec.lua b/lib/createRef.spec.lua new file mode 100644 index 00000000..e16115f0 --- /dev/null +++ b/lib/createRef.spec.lua @@ -0,0 +1,15 @@ +return function() + local createRef = require(script.Parent.createRef) + + it("should create refs", function() + expect(createRef()).to.be.ok() + end) + + it("should support tostring on refs", function() + local ref = createRef() + expect(tostring(ref)).to.equal("RoactReference(nil)") + + ref.current = "foo" + expect(tostring(ref)).to.equal("RoactReference(foo)") + end) +end \ No newline at end of file diff --git a/lib/init.lua b/lib/init.lua index c28e2528..80e9f801 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -4,6 +4,7 @@ local Component = require(script.Component) local Core = require(script.Core) +local createRef = require(script.createRef) local Event = require(script.Event) local Change = require(script.Change) local GlobalConfig = require(script.GlobalConfig) @@ -39,6 +40,7 @@ apply(Roact, ReconcilerCompat) apply(Roact, { Component = Component, + createRef = createRef, PureComponent = PureComponent, Event = Event, Change = Change, diff --git a/lib/init.spec.lua b/lib/init.spec.lua index bad989c0..480dd157 100644 --- a/lib/init.spec.lua +++ b/lib/init.spec.lua @@ -4,6 +4,7 @@ return function() it("should load with all public APIs", function() local publicApi = { createElement = "function", + createRef = "function", mount = "function", unmount = "function", reconcile = "function",