diff --git a/docs/api-reference.md b/docs/api-reference.md index d95954c5..8f59f892 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -381,4 +381,26 @@ end As with `setState`, you can set use the constant `Roact.None` to remove a field from the state. !!! note - `getDerivedStateFromProps` is a *static* lifecycle method. It does not have access to `self`, and must be a pure function. \ No newline at end of file + `getDerivedStateFromProps` is a *static* lifecycle method. It does not have access to `self`, and must be a pure function. + +### validateProps +``` +static validateProps(props) -> success, reason +``` + +Performs property validation. There is a [library called PropTypes](https://github.com/AmaranthineCodices/rbx-prop-types) that can be used to validate props declaratively. + +This function will only be called if the `propValidation` configuration option is set to `true`. If this function returns `false`, the error message it returns will be thrown in the output, along with a stack trace pointing to the current element. + +```lua +function MyComponent.validateProps(props) + if props.requiredProperty == nil then + return false, "requiredProperty is required" + end + + return false +end +``` + +!!! note + `validateProps`, like `getDerivedStateFromProps`, is a *static* lifecycle method. It does not have access to `self`, and must be a pure function. \ No newline at end of file diff --git a/lib/Component.lua b/lib/Component.lua index 81b68d62..6cd03949 100644 --- a/lib/Component.lua +++ b/lib/Component.lua @@ -77,6 +77,7 @@ function Component:extend(name) end class.__index = class + class._extendTraceback = debug.traceback() setmetatable(class, { __tostring = function(self) @@ -290,6 +291,10 @@ function Component:_update(newProps, newState) end end + if newProps then + self:_validateProps(newProps) + end + local startTime = tick() local doUpdate = self:shouldUpdate(newProps or self.props, newState or self.state) local elapsed = tick() - startTime @@ -378,6 +383,8 @@ function Component:_mount(handle) self._setStateBlockedReason = "render" + self:_validateProps(self.props) + local startTime = tick() local virtualTree = self:render() local elapsed = tick() - startTime @@ -424,4 +431,38 @@ function Component:_unmount() self._handle = nil end +--[[ + Performs property validation, if it is enabled. +]] +function Component:_validateProps(props) + if not GlobalConfig.getValue("propValidation") then + return + end + + local validator = self.validateProps + + if validator == nil then + return + end + + if typeof(validator) ~= "function" then + -- Hide as much as possible about error location, since this message + -- occurs from several possible call sites with different stack traces. + error(("The value of validateProps must be a function, but it is a %s.\n".. + "Check the definition of the component extended at:\n%s"):format( + typeof(validator), + self._extendTraceback), + 0) + end + + local success, failureReason = validator(props) + + if not success then + failureReason = failureReason or "" + error(("Property validation failed:\n%s\n%s"):format( + tostring(failureReason), + self:getElementTraceback() or Core._defaultElementTracebackMessage), 0) + end +end + return Component diff --git a/lib/Component.spec.lua b/lib/Component.spec.lua index c9a2fed4..105ca435 100644 --- a/lib/Component.spec.lua +++ b/lib/Component.spec.lua @@ -3,7 +3,6 @@ return function() local createElement = require(script.Parent.createElement) local Reconciler = require(script.Parent.Reconciler) local GlobalConfig = require(script.Parent.GlobalConfig) - local Component = require(script.Parent.Component) it("should be extendable", function() @@ -326,6 +325,106 @@ return function() Reconciler.unmount(handle) end) + describe("validateProps", function() + it("should be called if propValidation is set", function() + GlobalConfig.set({ + propValidation = true, + }) + + local TestComponent = Component:extend("TestComponent") + local callCount = 0 + + TestComponent.validateProps = function(props) + callCount = callCount + 1 + return true + end + + function TestComponent:render() + return nil + end + + local handle = Reconciler.mount(createElement(TestComponent)) + expect(callCount).to.equal(1) + + handle = Reconciler.reconcile(handle, createElement(TestComponent, { + foo = "bar", + })) + expect(callCount).to.equal(2) + + Reconciler.unmount(handle) + expect(callCount).to.equal(2) + + GlobalConfig.reset() + end) + + it("should throw if the function returns false", function() + GlobalConfig.set({ + propValidation = true, + }) + + local TestComponent = Component:extend("TestComponent") + + TestComponent.validateProps = function(props) + return false + end + + function TestComponent:render() + return nil + end + + expect(function() + Reconciler.mount(createElement(TestComponent)) + end).to.throw() + + GlobalConfig.reset() + end) + + it("should throw if validateProps is not a function", function() + GlobalConfig.set({ + propValidation = true, + }) + + local TestComponent = Component:extend("TestComponent") + + TestComponent.validateProps = false + + function TestComponent:render() + return nil + end + + expect(function() + Reconciler.mount(createElement(TestComponent)) + end).to.throw() + + GlobalConfig.reset() + end) + + it("should not be run if propValidation is false", function() + local TestComponent = Component:extend("TestComponent") + local callCount = 0 + + TestComponent.validateProps = function(props) + callCount = callCount + 1 + return true + end + + function TestComponent:render() + return nil + end + + local handle = Reconciler.mount(createElement(TestComponent)) + expect(callCount).to.equal(0) + + handle = Reconciler.reconcile(handle, createElement(TestComponent, { + foo = "bar", + })) + expect(callCount).to.equal(0) + + Reconciler.unmount(handle) + expect(callCount).to.equal(0) + end) + end) + describe("setState", function() it("should throw when called in init", function() local InitComponent = Component:extend("InitComponent") diff --git a/lib/Config.lua b/lib/Config.lua index ced1fc65..3311b488 100644 --- a/lib/Config.lua +++ b/lib/Config.lua @@ -18,6 +18,8 @@ local defaultConfig = { ["elementTracing"] = false, -- Enables instrumentation of shouldUpdate and render methods for Roact components ["componentInstrumentation"] = false, + -- Enables type checking with the validateProps property on stateful components. + ["propValidation"] = false, } -- Build a list of valid configuration values up for debug messages. diff --git a/lib/Core.lua b/lib/Core.lua index 76d96580..03a5e006 100644 --- a/lib/Core.lua +++ b/lib/Core.lua @@ -21,4 +21,8 @@ Core.None = Symbol.named("None") -- Marker used to specify that the table it is present within is a component. Core.Element = Symbol.named("Element") +-- The default "stack traceback" if element tracing is not enabled. +-- luacheck: ignore 6 +Core._defaultElementTracebackMessage = "\n\t\n" + return Core \ No newline at end of file diff --git a/lib/Reconciler.lua b/lib/Reconciler.lua index 329045e2..9c114be9 100644 --- a/lib/Reconciler.lua +++ b/lib/Reconciler.lua @@ -34,8 +34,6 @@ local Symbol = require(script.Parent.Symbol) local isInstanceHandle = Symbol.named("isInstanceHandle") -local DEFAULT_SOURCE = "\n\t\n" - local ElementKind = { None = Symbol.named("ElementKind.None"), Portal = Symbol.named("ElementKind.Portal"), @@ -500,7 +498,7 @@ function Reconciler._setRbxProp(rbx, key, value, element) local success, err = pcall(set, rbx, key, value) if not success then - local source = element.source or DEFAULT_SOURCE + local source = element.source or Core._defaultElementTracebackMessage local message = ("Failed to set property %s on primitive instance of class %s\n%s\n%s"):format( key, @@ -519,7 +517,7 @@ function Reconciler._setRbxProp(rbx, key, value, element) elseif key.type == Change then Reconciler._singleEventManager:connectProperty(rbx, key.name, value) else - local source = element.source or DEFAULT_SOURCE + local source = element.source or Core._defaultElementTracebackMessage -- luacheck: ignore 6 local message = ("Failed to set special property on primitive instance of class %s\nInvalid special property type %q\n%s"):format( @@ -534,7 +532,7 @@ function Reconciler._setRbxProp(rbx, key, value, element) -- Userdata values are special markers, usually created by Symbol -- They have no data attached other than being unique keys - local source = element.source or DEFAULT_SOURCE + local source = element.source or Core._defaultElementTracebackMessage local message = ("Properties with a key type of %q are not supported\n%s"):format( type(key),