dyo / dyo

Dyo is a JavaScript library for building user interfaces.

Home Page:https://dyo.js.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Adding support for prop validation (aka "propTypes") to DIO

mcjazzyfunky opened this issue · comments

As discussed in #71 it would be nice if DIO had support for propTypes validation, as React has.
In the next release of DIO there will be separated packages for DEVELOPMENT and PRODUCTION (as in React), see #72.
So it's possible to add propTypes validation to DIO that will only be performed in DEVELOPMENT mode and NOT in PRODUCTION.

Task description:

  • Add propTypes and contextTypes validation support for DIO component classes and DIO stateless functional components (no need to implement support for "childContextTypes").
  • Add propTypes validation for ContextProviders (=> new context API)
  • Make sure that the above mentioned changes have the same API and behavior as in React, and also make sure that all the changes for the propType validation logic are not included in DIO's PRODUCTION packages.

For everybody's information:
I've created branch "issue76" for this task.
Please be aware that the state of this branch is currently not final (missing unit tests, missing support for contextTypes etc.).
Also be aware that the new folder "demo" will be removed in the final version (as the added unit tests will cover everything then).

@thysultan
Could you please do me a favor and switch to branch "issue76", open "demo/demo.html" in the browser and have a look at the output in the browser console?
You'll see some (intended) propType validation errors there and in the second line you see the path to the erroneous components. Unfortunately those paths are mainly describing the paths in the DOM tree not in the component tree (see function "getComponentPath" in file "Component.js").
Is there a way to determine a more helpful component path (showing also the display names of all the parent components) if you have the DIO's virtual element of the component as input?

On the second point Add propTypes validation for ContextProviders (=> new context API), i'll handle this specific task and contextTypes after you're done.

On the topic of component trace instead of returning an error you could throw and let error boundaries handle that part in which case createErrorStack from Error.js would handle building the component trace.

BTW the implementation of propTypes https://github.com/thysultan/dio.js/blob/issue76/src/Core/Element.js#L526 should resemble defaultProps and only needs to touch that part of the createElement function.

@thysultan
Regarding "you could throw and let error boundaries handle":
Frankly, in the past, I always considered it a bad idea that React just printed out a warning about a failed prop validation instead of throwing an exception. But later I was convinced by the React team that it is better if the only difference in the visible behavior between DEVELOPMENT and PRODUCTION is just printing warnings and NOTHING else.
If we throw a propType validation error the behaviour on DEV may be completely different than on PROD (at least the location of the error will surely be different).
Are you still sure that we should throw an error or shall we stay with printing out warnings?

But later I was convinced by the React team that it is better if the only difference in the behavior between DEVELOPMENT and PRODUCTION is just printing warnings and NOTHING else.

The opposite is often true when considering a testing framework might not necessarily fail on console.error invocations and that propTypes are inherently linked to the concept of catching errors eagerly, of which console.error strikes me as introducing more gaps for these to slip through un-noticed.

@thysultan
Okay, I've switched to throwing exceptions instead of printing warnings.
I do not want to sound annoying but I really don't consider this very strict "throwing errors" solution to be optimal.
What happens to all the other possible future DEVELOPMENT warnings like "missing key props for arrays and other iterables" or warnings that a iterator is used instead of an iterable (generators instead of generator functions) as child (see facebook/react#13312 )? Will they also result in such a "hard" error?
Often in React you meet warnings about severe errors that really should be fixed as soon as possible, but yet "as soon as possible" does not necessarily mean "immediately" and so it's up to you whether you fix things really immediately or maybe in two hours. With the strict "throwing errors" solution you do not have that comfort or choice any longer but instead you have to fix each of those issues immediately.

What about some kind of "best-of-both-worlds" solution?

function printError(error, element, origin) {
    // ...
    console.error(errorMsg);

   if (typeof DIO_FORCE_THROWING_ERRORS !== 'undefined'
     && (DIO_FORCE_THROWING_ERRORS === 'true' || DIO_FORCE_THROWING_ERRORS === true)
     || typeof process !== 'undefined' && process && process.env
     && process.env.DIO_FORCE_THROWING_ERRORS === 'true') {

     throwErrorException(element, error, origin)
   }
}

👍 throwing (for testing and correctness)
👎 env switch

warnings like "missing key props for arrays and other iterables"

I'm specifically against adding this particular warning in the future as has been demonstrated by the React issue, this introduces other unresolved issues with stateful iterables, especially when considering that DIO has first-class support for stateful sync and async iterators. There are also other valid cases where not using keys is intentional, i.e static fragments, etc.

That said another point related to this is, is that since propTypes are primarily intentional opt-in validation primitives, they are fundamentally different from helpful warnings whereby in the case of warnings most of the time you might find yourself guessing wether the author did or did not intend to do something or somethings else, propTypes on the other hand are explicit in comparison.

As an example trying to render to an invalid root i.e a number dio.render(<div><div>, 1) would throw an exception the same way that document.createElement will throw when given arguments that can be proven to be 100% incorrect, or how static typing errors in TypeScript would prevent a compile etc.

Following this same process i think propTypes given their nature should follow suit as they are far less in the category of warnings and more an explicit signal of undefined behavior for components that implements them.

Thanks @Zolmeister, thanks @thysultan => so we stay with the "throwing solution"

@thysultan
In branch "issue76" you find the latest implementation status.
Everything regarding propTypes validation seems to be implemented (not "contextTypes" - you've said you want to take care of that yourself), except for the unit tests.
But before I start implementing the unit tests: Could you please pre-review the current implementation in "issue76" and tell me whether this is what you had in mind or not? Could help me to save some time in case that you want some major changes which could have impact on the unit tests.
Thanks a lot in advance.

Some remarks:

  • Like said, the "demo" directory will be removed later

  • You can see all possible error message types if you comment out that "throwErrorException" call at the end of file "Component.js", npm build and open file "demo/demo.html" in the browser.

  • @thysultan, you've said you'd implement the support of the "contextTypes" yourself. Nevertheless, I've already implemented that for the "new context API" (not for the "old" one) in file "Context.js":
    19a35fe#diff-efc5e98bab1f03ac75a09c1c7cccde93

    Please let me know if you had something completely different in mind and I should therefore revert my changes in file "Context.js".

  • My enhancements in file "types/typescript.d.ts" are not very nice =>the typings are a bit too lax. But frankly I think there are already other typings that are also not strict enough (for example "type: Function" in "interface createElement"). The generic type P of "abstract class Component<P = {}, ...>" is not available for the static class members "defaultProps", "propTypes" and "contextTypes".
    I think you need some extra interface as it is done here for React:

    https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts#L358

    But that would imply quite a lot of changes in the whole file, which is not really in the scope of this issue here => therefore I would suggest to leave the type definitions as is and perform a major revise on them somewhere in future in a dedicated task.

The example comment in #71 is a good place to start, the intention is to have all the validation specific logic live in the authored validator implementation outside of DIO. Where by DIO's job is to invoke and pass relevant data on to that function.

For example in Element.js#L521 you might implement this like.

case SharedElementComponent:
  if (type[SharedDefaultProps])
    defaults(props, getDefaultProps(element, type, props))
  break
// ------
if (process.env.NODE_ENV === "development")
  if (type.propTypes)
    for (var name in type.propTypes)
      if typeof type.propTypes[name] == 'function'
        type.propTypes[name](name, props, type)
    // or create a function that does this like we do with getDefaultProps

In a trip through memory lane, in 3.4.0 this was implemented like this PropTypes.js#L62

Sultan, you've said you'd implement the support of the "contextTypes" yourself. Nevertheless, I've already implemented that for the "new context API" (not for the "old" one) in file "Context.js":

Edit: Yes the intention is to return a component reference like we did in a previous minor version. That way supporting this is implied as long as Components support this. It's similar to what you already have.

the typings are a bit too lax.

Yes the typings have a lot of room for improvement. It hasn't been at the top of my list since i haven't used TypeScript in a while.

@thysultan Thanks for your answers.

Two things:

  1. If we add the "checkPropTypes(element, props)" call inside that "createElement" function and an validation error occurs, then - at least according to my tests - we do not seem to get a proper error stack when calling "throwErrorException(element ...)".
    I therefore thought it would be better to call "checkPropTypes" here
    https://github.com/thysultan/dio.js/blob/issue76/src/Core/Component.js#L245
    and here
    https://github.com/thysultan/dio.js/blob/issue76/src/Core/Component.js#L310
    Bad idea?

  2. If you have something like

    MyComponent.propTypes = {
        name: PropTypes.string
    }

    then in React that means PropTypes.string will either return null or an error - it will NOT throw the error.

    In your example the validator function seems not to return anything (means, it should throw the error itself):

    if typeof type.propTypes[name] == 'function'
        type.propTypes[name](name, props, type) // <- NO RETURN VALUE!!!!

    Is this really what you want? That would be a different API than in React.

We don't need to throwErrorException i think we can just vanilla throw: Something along the lines of this might satisfy both the mentioned points:

function getPropTypes (element, type, props) {
	if (typeof type[SharedPropTypes] === 'function')
		getPropTypes(element, ObjectDefineProperty(type, SharedPropTypes, {value: type[SharedPropTypes](props)}), props)
	else for (var name in type[SharedPropTypes])
		checkPropTypes(type[SharedPropTypes][name], name, type, props)
}
function checkPropTypes (value, name, type, props) {
	if (typeof value === 'function')
		checkPropTypes(value(name, type, props), name, type, props, getDisplayName(type))
	else if (value instanceof Error)
		throw value
}

Though maybe instead of or in addition to instanceof we could check if the object is Error-like to support cross realm instances.

React does something similar by doing this in createElement ReactElementValidator.js#L330

@thysultan
Thanks for your answers and hints.
Okay, I've changed the following accordingly:

  • I've moved the "checkPropTypes" invocation to "createElement"
  • The check whether the result value of the validation function is an error object is "cross realm" capable now
  • In case of a validation error a vanilla "throw error" is used instead of "throwErrorException" now

All changes are pushed to "origin/issue76".
Will start to implement the unit tests as soon as I find time to do so.

@mcjazzyfunky checkPropTypes feels a bit more involved than is necessary. For example React doesn't include logic related to logging and delegates that to the prop-types package.

The previously mentioned checkPropTypes function would probably be enough to match React behaviour.

I don't think we need to wrap checkPropTypes in if (process.env.NODE_ENV === 'development'), if i'm not mistaken uglify-js will remove the unused function after it removes the checkPropTypes branch in createElement. (Could be wrong, we need to test this).

@thysultan Thanks for your remarks. Here's my response:

  1. If you wrap the definition of functions that are only meant to be used on DEV inside a "NODE_ENV switch" then that has the advantage that those functions can only be used if the function application itself is also wrapped into a "NODE_ENV switch", otherwise your PRODUCTION unit tests will fail.
    If those DEV function are NOT wrapped into a "NODE_ENV switch" there are handled like any other function and there will not be any failures in the PRODUCTION unit tests to alert you that you've forgotten to use the "NODE_ENV switch".
    But that pattern obviously seems a bit confusing and the above mentioned advantage is surely not THAT important, therefore I will remove the "NODE_ENV switch" around "function checkPropTypes", of course, as you've suggested (BTW: will work as you've described above -> I have tested it).

  2. Currently in DIO.js, when a ErrorBoundary component just "swallows" an exception {=> componentDidCatch() { /* do nothing */ }) there won't be any response to the user that a error has happened. Therefore, also those propType validation errors would just be "swallowed" without any warning.
    That was one reason why I've added the "console.error" invocation in "checkPropTypes".

    I will do the following: I'll remove this "console.error" invocation in "checkProps" types as you've suggested and instead make sure that on DEV (and only there) in case that some ErrorBoundary component will handle an error, that error will (on DEV) ALWAYS be printed out on console independent of the implementation of "componentDidCatch". This btw is the behavior of React, see https://jsfiddle.net/ces8w5zt/

For the remaining topics I'll write an other comment later as things are not 100% clear => to be continued...

@thysultan [....continuation of my latest comment]

Franky, I really do not understand that "more envolved than necessary" issue with the current implementation of "checkPropTypes".
I always, of course, understand if anyone prefers some other code structure (splitting in smaller functions, devinding in "checkPropTypes" and "checkContextTypes" instead of using the argument "checkForContext" etc.) and I am, of course, always willing to optimize whenever or wherever there are issues or doubts.

But here I currently really cannot understand where the "checkPropTypes" function is doing "more [...] than necessary".

  1. It checks whether a validator is really a function and throws an error if not
  2. It invokes the validator functions to perform a prop validation
  3. In case that an exception is thrown (for what reasons ever) inside of the validator function, it catches that exception, enriches the error message with some other useful information and throws an error
  4. It checks whether the result value of the validator function is really either null or an error(-like) object, throws on error otherwise
  5. It throws an error if the validator function has returned an error object

In you your example code snippet above only the points 2, 5 are handled - who shall handle the points 1, 3 and 4 (or let me ask: Whom shall the resposibilities for points 1, 3, and 4 be delegated to)?

Maybe I am wrong, but I do not see some kind of "loose coupling" between React and the "checkPropTypes" function of the "prop-types" package (isn't it just coupled hard-coded?)

Moreover, in your example code snippet I do not really understand why it provides support for the following two cases:
a) typeof type[SharedPropTypes] === 'function'
b) type[SharedPropTypes][somePropName] instanceof Error
What are the use cases for that?

Thank you very much in advance for your clarifications.

If those DEV function are NOT wrapped into a "NODE_ENV switch" there are handled like any other function and there will not be any failures in the PRODUCTION unit tests

I don't think this would pose a problem as tests are only ever written against the public API. i.e a production test could only ever add a getter("propTypes") to a function and invariant if accessed to test this.

Currently in DIO.js, when a ErrorBoundary component just "swallows" an exception {=> componentDidCatch() { /* do nothing */ }) there won't be any response to the user that a error has happened... instead make sure that on DEV (and only there) in case that some ErrorBoundary component will handle an error, that error will (on DEV) ALWAYS be printed out on console independent of the implementation of "componentDidCatch". This btw is the behavior of React.

This is intentional.

In most cases React's Error Boundaries can be consider identical to JavaScript try...catch exception control with the exception of this specific detail, for example the issues discussed in 10474 and 11098 detail the un-favourable(or non try...catch-like) behaviour this tries to avoid. Logging indiscriminately would thus break this heuristic.

"checkPropTypes" and "checkContextTypes"

Yes for now lets not worry about contextTypes. We can refactor for this case in a separate PR or pass the mentioned property as an argument i.e:

if (type[SharedPropTypes])
-    getPropTypes(element, type, props)
+    getPropTypes(element, type, props, SharedPropTypes)
  1. It checks whether a validator is really a function and throws an error if not

I don't think we need to do this. If it's not a function it would throw when invoked.

  1. In case that an exception is thrown (for what reasons ever) inside of the validator function, it catches that exception, enriches the error message with some other useful information and throws an error.

Lets let it propagate as it is. Error Boundaries compute a componentStack trace when they propagate we can piggyback on that for now.

It checks whether the result value of the validator function is really either null or an error(-like) object, throws on error otherwise

Lets skip this step for now and merge it with the last step when we refactor for the contextTypes case.

Maybe I am wrong, but I do not see some kind of "loose coupling" between React and the "checkPropTypes" function in the "prop-types" package (isn't it just coupled hard-coded?)

Yes i was mistaken about that point.

That said we want to implement the least amount necessary initially and see what that affords us.

For example logging like in React's case would introduce the same problems surrounding Error Boundaries logging even though they where caught by componentDidCatch, which can sometimes plague the console in tests that intentionally do this.

In which case we shouldn't try to special case propType exceptions but treat them like any other exception that can be caught.

Moreover, in your example code snippet I do not really understand why it provides support for the following two cases: a) typeof type[SharedPropTypes] === 'function' b) type[SharedPropType][somePropName] instanceof Error What are the use cases for that?

The first case(a) is the ability to support:

class A {
static propTypes() { return {} }
}

Albeit this is motivated in part by the fact that class fields where and or are not part of the ECMAScript spec as it stands. So this allows you to do the above instead of the following when writing for non-transpiled code.

class A {}
A.propTypes = {}

We afford the same allowance to defaultProps.

b) type[SharedPropType][somePropName] instanceof Error

Looks like an Error, In (b) the value was either intended to be the result of the validation function invocation or the function to be invoked. But based on the mentioned details in this comment it probably shouldn't be, i.e: just calling the value and letting it throw if it's not callable.

- checkPropTypes(type[SharedPropTypes][name], name, type, props)
+ checkPropTypes(type[SharedPropTypes][name](name, type, props, getDisplayName(type)))
function checkPropTypes (value) {
-     if (value instanceof Error)
+     if (value) // maybe this is enough(consider any truthy value as cause for exception)
        throw value
}

@thysultan Thank you very much for that very detailed clarification. I get it now and I'll change the code accordingly.

One last thing: Sorry, but I really do not like that "propTypes as a function" thing and that side effect in "getPropTypes". Of course I'll implement it if you like it to be implemented that way, just asking whether you are really sure?

class MyComponent extends Component {
  static defaultProps(props) {
     // this is some interesting feature, more powerful than in React
     // as you have the current props as argument
     ...
  }

  static propTypes(props) {
    // Why should the propTypes depend on the (initial) props?!?
    //
    // And why not just use (if you do not want to use: MyComponent.propTypes = ...):
    //
    //   static get propTypes() {
    //     return propTypesAsDefinedSomewhereAbove
    //   }
    ...
  }

  ...
}

An example(similar to the use of of a static defaultProps function): Dynamic propTypes based on data(component, name, value, etc?).

class Base
  static propTypes(props) {
    return Object.keys(props).reduce((acc, val) => {
        return acc[val] = getPropType(this, props, name, val), acc
    }, {})
  }
}

This can be used on a base class that other components extend and that assigns propTypes based on the current component that has extended it.

That said that reveals an error(side-effect) in the snippet i posted.

-function getPropTypes (element, type, props) {
+function getPropTypes (element, type, props, validators) {
	if (typeof type[SharedPropTypes] === 'function')
-		getPropTypes(element, ObjectDefineProperty(type, SharedPropTypes, {value: type[SharedPropTypes](props)}), props)s)
+		getPropTypes(element, type, props, type[SharedPropTypes](props))
	else for (var name in type[SharedPropTypes])
		checkPropTypes(type[SharedPropTypes][name](name, type, props))
}

@aMarCruz I don't understand? propTypes are optional in ReactDOM as well and the intended feature should be optional(in the opt-in development build) in DIO as well.

@thysultan ,
I do not know if it applies to React, but at the rate that React Native changes it is likely that propTypes will be removed in a short time. I would not worry too much about it.

We plan to migrate native components like View, Text, and Image to be typed with Flow instead of propTypes...