diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b343718..af648228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased Changes * Improved the error message when an invalid changed hook name is used. ([#216](https://github.com/Roblox/roact/pull/216)) * Fixed a bug where fragments could not be used as children of an element or another fragment. ([#214](https://github.com/Roblox/roact/pull/214)) +* Added Roact.Type, Roact.typeOf, and Roact.isComponent for Roact object and component type checking. ([#230](https://github.com/Roblox/roact/pull/230)) ## [1.1.0](https://github.com/Roblox/roact/releases/tag/v1.1.0) (June 3rd, 2019) * 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)) diff --git a/docs/advanced/type-validation.md b/docs/advanced/type-validation.md new file mode 100644 index 00000000..57856dcb --- /dev/null +++ b/docs/advanced/type-validation.md @@ -0,0 +1,62 @@ +In certain situations, such as when building reusable and customizable components, props may be composed of Roact objects, such as an element or a component. + +To facilitate safer development for these kinds of situations, Roact provides the `Roact.typeOf` and `Roact.isComponent` functions to help validate these objects. + +## Roact Object Type Validation + +Suppose we want to write a `Header` component with a prop for the title child element: +```lua +local Header = Component:extend("Header") + +function Header:render() + local title = props.title + + return Roact.createElement("Frame", { + -- Props for Frame... + }, { + Title = title + }) +end +``` + +Now suppose we want to validate that `title` is actually an element using [validateProps](../../api-reference/#validateprops). With `Roact.typeOf` we can be certain we have a Roact Element: +```lua +Header.validateProps = function() + local title = props.title + + if Roact.typeOf(title) == Roact.Type.Element then + return true + end + + return false, "prop title is not an element" +end +``` + +## Component Type Validation + +In some cases, a component will be more preferable as a prop than an element. `Roact.isComponent` can be used to see if a value is a plausible component and thus can be passed to `Roact.createElement`. + +```lua +local Header = Component:extend("Header") + +Header.validateProps = function() + local title = props.title + + if Roact.isComponent(title) then + return true + end + + return false, "prop title can not be an element" +end + +function Header:render() + local title = props.title + return Roact.createElement("Frame", { + -- Props for Frame... + }, { + Title = Roact.isComponent(title) and Roact.createElement(title, { + -- Props for Title... + }) + }) +end +``` \ No newline at end of file diff --git a/docs/api-reference.md b/docs/api-reference.md index a51216ca..7465d380 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -167,6 +167,28 @@ end --- +### Roact.typeOf +
Added in 1.2.0
+ +``` +Roact.typeOf(roactObject) -> Roact.Type +``` + +Returns the [Roact.Type](#roacttype) of the passed in Roact object, or `nil` if the input is not a Roact object. + +--- + +### Roact.isComponent +
Added in 1.2.0
+ +``` +Roact.isComponent(value) -> bool +``` + +Returns true is the provided value can be used by [Roact.createElement](#roactcreateelement). + +--- + ### Roact.createRef ``` Roact.createRef() -> Ref @@ -356,6 +378,48 @@ See [the Portals guide](../advanced/portals) for a small tutorial and more detai --- +## Enumerations + +### Roact.Type +
Added in 1.2.0
+ +An enumeration of the various types of objects in Roact, returned from calling `Roact.typeOf` on Roact objects. + +#### Roact.Type.Binding +`Roact.typeOf` object returned from `Roact.createBinding` + +--- + +#### Roact.Type.Element +`Roact.typeOf` object returned from `Roact.createElement` + +--- + +#### Roact.Type.HostChangeEvent +`Roact.typeOf` object returned when indexing into `Roact.Change` + +--- + +#### Roact.Type.HostEvent +`Roact.typeOf` object returned when indexing into `Roact.Event` + +--- + +#### Roact.Type.StatefulComponentClass +`Roact.typeOf` object returned from `Roact.Component:extend` + +--- + +#### Roact.Type.StatefulComponentInstance +`Roact.typeOf` object of self inside of member methods of `Roact.Component` + +--- + +#### Roact.Type.VirtualTree +`Roact.typeOf` object returned by `Roact.mount` + +--- + ## Component API ### defaultProps diff --git a/mkdocs.yml b/mkdocs.yml index 94430efe..09b9d707 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ nav: - Portals: advanced/portals.md - Bindings and Refs: advanced/bindings-and-refs.md - Context: advanced/context.md + - Type Validation: advanced/type-validation.md - Performance Optimization: - Overview: performance/overview.md - Reduce Reconcilation: performance/reduce-reconciliation.md diff --git a/src/Type.lua b/src/Type.lua index 156ee0ea..d2ca945a 100644 --- a/src/Type.lua +++ b/src/Type.lua @@ -15,9 +15,12 @@ local strict = require(script.Parent.strict) local Type = newproxy(true) local TypeInternal = {} +local TypeNames = {} local function addType(name) - TypeInternal[name] = Symbol.named("Roact" .. name) + local symbol = Symbol.named("Roact" .. name) + TypeNames[symbol] = name + TypeInternal[name] = symbol end addType("Binding") @@ -37,6 +40,14 @@ function TypeInternal.of(value) return value[Type] end +function TypeInternal.nameOf(type) + if typeof(type) ~= "userdata" then + return nil + end + + return TypeNames[type] +end + getmetatable(Type).__index = TypeInternal getmetatable(Type).__tostring = function() @@ -44,5 +55,6 @@ getmetatable(Type).__tostring = function() end strict(TypeInternal, "Type") +strict(TypeNames, "TypeNames") return Type \ No newline at end of file diff --git a/src/Type.spec.lua b/src/Type.spec.lua index f2477093..f5336767 100644 --- a/src/Type.spec.lua +++ b/src/Type.spec.lua @@ -20,5 +20,9 @@ return function() expect(Type.of(test)).to.equal(Type.Element) end) + + it("should return a type's name", function() + expect(Type.nameOf(Type.Element)).to.equal("Element") + end) end) end \ No newline at end of file diff --git a/src/TypeMirror.lua b/src/TypeMirror.lua new file mode 100644 index 00000000..091118ed --- /dev/null +++ b/src/TypeMirror.lua @@ -0,0 +1,53 @@ +--[[ + Mirrors a subset of values from Type.lua for external use, allowing + type checking on Roact objects without exposing internal Type symbols + + TypeMirror: { + Type: Roact.Type, + typeOf: function(value: table) -> Roact.Type | nil + } +]] + +local Type = require(script.Parent.Type) +local Symbol = require(script.Parent.Symbol) +local strict = require(script.Parent.strict) + +local ALLOWED_TYPES = { + Type.Binding, + Type.Element, + Type.HostChangeEvent, + Type.HostEvent, + Type.StatefulComponentClass, + Type.StatefulComponentInstance, + Type.VirtualTree +} + +local MirroredType = {} +for _, type in ipairs(ALLOWED_TYPES) do + local name = Type.nameOf(type) + MirroredType[name] = Symbol.named("Roact" .. name) +end + +setmetatable(MirroredType, { + __tostring = function() + return "RoactType" + end +}) + +strict(MirroredType, "Type") + +local Mirror = { + typeList = ALLOWED_TYPES, + Type = MirroredType, + typeOf = function(value) + local name = Type.nameOf(Type.of(value)) + if not name then + return nil + end + return MirroredType[name] + end, +} + +strict(Mirror, "TypeMirror") + +return Mirror \ No newline at end of file diff --git a/src/TypeMirror.spec.lua b/src/TypeMirror.spec.lua new file mode 100644 index 00000000..46448777 --- /dev/null +++ b/src/TypeMirror.spec.lua @@ -0,0 +1,53 @@ +return function() + local Type = require(script.Parent.Type) + local Mirror = require(script.Parent.TypeMirror) + + describe("Type", function() + it("should return a mirror of an internal type", function() + local name = Type.nameOf(Type.Element) + local mirroredType = Mirror.Type[name] + expect(mirroredType).to.equal(Mirror.Type.Element) + end) + + it("should not return the actual internal type", function() + local name = Type.nameOf(Type.Element) + local mirroredType = Mirror.Type[name] + expect(mirroredType).to.never.equal(Type.Element) + end) + + it("should include all allowed types", function() + for _, type in ipairs(Mirror.typeList) do + local name = Type.nameOf(type) + local mirroredType = Mirror.Type[name] + expect(mirroredType).to.be.ok() + end + end) + + it("should not include any other types", function() + local name = Type.nameOf(Type.VirtualNode) + local success = pcall(function() + local _ = Mirror.Type[name] + end) + expect(success).to.equal(false) + end) + end) + + describe("typeOf", function() + it("should return nil if the value is not a valid type", function() + expect(Mirror.typeOf(1)).to.equal(nil) + expect(Mirror.typeOf(true)).to.equal(nil) + expect(Mirror.typeOf"test").to.equal(nil) + expect(Mirror.typeOf(print)).to.equal(nil) + expect(Mirror.typeOf({})).to.equal(nil) + expect(Mirror.typeOf(newproxy(true))).to.equal(nil) + end) + + it("should return the assigned type", function() + local test = { + [Type] = Type.Element + } + + expect(Mirror.typeOf(test)).to.equal(Mirror.Type.Element) + end) + end) +end \ No newline at end of file diff --git a/src/init.lua b/src/init.lua index f002f975..35866824 100644 --- a/src/init.lua +++ b/src/init.lua @@ -8,6 +8,7 @@ local createReconcilerCompat = require(script.createReconcilerCompat) local RobloxRenderer = require(script.RobloxRenderer) local strict = require(script.strict) local Binding = require(script.Binding) +local TypeMirror = require(script.TypeMirror) local robloxReconciler = createReconciler(RobloxRenderer) local reconcilerCompat = createReconcilerCompat(robloxReconciler) @@ -37,6 +38,10 @@ local Roact = strict { teardown = reconcilerCompat.teardown, reconcile = reconcilerCompat.reconcile, + isComponent = require(script.isComponent), + typeOf = TypeMirror.typeOf, + Type = TypeMirror.Type, + setGlobalConfig = GlobalConfig.set, -- APIs that may change in the future without warning diff --git a/src/init.spec.lua b/src/init.spec.lua index 7fcf79c8..c291ac73 100644 --- a/src/init.spec.lua +++ b/src/init.spec.lua @@ -13,6 +13,8 @@ return function() update = "function", oneChild = "function", setGlobalConfig = "function", + typeOf = "function", + isComponent = "function", -- These functions are deprecated and throw warnings! reify = "function", @@ -26,6 +28,7 @@ return function() Event = true, Change = true, Ref = true, + Type = true, None = true, UNSTABLE = true, } diff --git a/src/isComponent.lua b/src/isComponent.lua new file mode 100644 index 00000000..e87dc94d --- /dev/null +++ b/src/isComponent.lua @@ -0,0 +1,14 @@ +local Portal = require(script.Parent.Portal) +local Type = require(script.Parent.Type) + +-- Returns true if the provided object can be used by Roact.createElement +return function(value) + local valueType = type(value) + + local isComponentClass = Type.of(value) == Type.StatefulComponentClass + local isValidFunctionComponentType = valueType == "function" + local isValidHostType = valueType == "string" + local isPortal = value == Portal + + return isComponentClass or isValidFunctionComponentType or isValidHostType or isPortal +end \ No newline at end of file diff --git a/src/isComponent.spec.lua b/src/isComponent.spec.lua new file mode 100644 index 00000000..44a02201 --- /dev/null +++ b/src/isComponent.spec.lua @@ -0,0 +1,33 @@ +local Component = require(script.Parent.Component) +local Portal = require(script.Parent.Portal) +local createElement = require(script.Parent.createElement) +local isComponent = require(script.Parent.isComponent) + +return function() + it("should return true for a stateful component class", function() + local MyStatefulComponent = Component:extend("MyStatefulComponent") + expect(isComponent(MyStatefulComponent)).to.equal(true) + end) + + it("should return true for a function component", function() + local MyFunctionComponent = function(props) + return createElement("Frame", {}) + end + expect(isComponent(MyFunctionComponent)).to.equal(true) + end) + + -- There's no way to guarantee the return type for a function in Lua at the moment + itSKIP("should not return true for a function that returns an invalid type", function() end) + + it("should return true for a string representing a host instance type", function() + local host = "Frame" + expect(isComponent(host)).to.equal(true) + end) + + -- In the future, an exhaustive enum of all possible host instance types could enable this check + itSKIP("should not return true for an invalid host component name", function() end) + + it("should return true for a portal", function() + expect(isComponent(Portal)).to.equal(true) + end) +end \ No newline at end of file