Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inline props as object #66

Merged
merged 28 commits into from
Dec 5, 2021
Merged

Inline props as object #66

merged 28 commits into from
Dec 5, 2021

Conversation

davesnx
Copy link
Member

@davesnx davesnx commented Nov 19, 2021

Fixes #43.

This PR changes the interface for React.Dom.domProps. Before these changes, domProps was a function with ~400 optional labelled arguments, after these changes we treat it as an object.

There are a few reasons for this change, but the most important is bundle size. jsoo compiles the optional arguments into zeros generating per each domProps call near 400 zeros, jsoo treated labeled arguments as positions into arrays. There are a few issues opened about this, I only found this one though: ocsigen/js_of_ocaml#801

Another of the reasons is about correctness, props in React are an object and after those changes, we treat it as an Object as well. Giving the same flexibility as the underneath layer could enable features that aren't possible on reason-react or rescript/react, for example adding missing attributes, allowing destructuring props in bindings, allowing "data"-attributes or type-safety on certain attributes.

Todos

  • Basic functionality with attributes
  • What should we do with ref and key
  • Type-safe attributes
  • Type-safe events
  • Add suppressContentEditableWarning
  • Add dangerouslySetInnerHTML
  • Update snapshot tests
  • Add a few test cases
  • Fix test baseSuite:refs:forwardRef (maybe it's related with my doubt? probably no)
  • Analyze better the bundle-size
  • Re-visit dangerouslySetInnerHTML type/format.
  • Fix locations for unexpected prop
  • Understand why there's nonlabelled props there?

Doubts

I have a doubt about the ref prop. Right now the ref is typed with React.Dom.Ref.t option but React.Dom.domRef doesn't seem to return an optional. But I believe the bindings are not the same as reason-react since they use an anonymous type from the useRef.

File "example/src/Code.re", line 14, characters 14-45:
14 |     <code ref={React.Dom.Ref.domRef(codeRef)}> {text |> React.string} </code>
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This expression has type React.Dom.domRef
       but an expression was expected of type React.Dom.domRef option

Future

As mentioned before, this change allows us a few niceties on the dom attributes API, from the top of my head:

  • -We could add more type-safety on many properties:
    • polyvariants (for example ?preload is now string (* "none", "metadata" or "auto" (and "" as a synonym for "auto") *) into 'None | 'Metadata | 'Auto)
    • dates (for example: max (* should be int or Js.Date.t *) into Js.Date
    • regex
    • etc
  • We could add warnings/errors about deprecated attributes
  • Try to implement "data" attributes (if the propName starts with "data", we render the value without any typecheck.

PS: I have the editor configured to format using ocamlformat, I believe we should run it across all files (I separate the formatting into one commit, in order to not affect the review)

@davesnx davesnx requested a review from jchavarri November 19, 2021 23:29
Copy link
Collaborator

@jchavarri jchavarri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davesnx This is super cool. 😍

Re: the refs, there is some context about these decisions in reasonml/reason-react#421 (comment). Essentially forwardRef is unsafe in ReasonReact so I tried to fix that by introducing a new type for refs for dom elements (Dom.Ref.t).

After your changes, I just realized that we could keep the ref as non-optional, and actually it makes sense to do so, because in some cases (like when using useRef) we get a {current: elementOrNull} JS object, so converting back and forth was not trivial.

I updated the tests as well, and checked that the ref examples were working correctly.

Another thing, I checked the bundle size of the example (built with profile=prod) and we went from 200KB to 117KB for the file App.bc.js. 🎉 This is all application code (not react or prism libs), but I think it's a huge improvement.

Giving the same flexibility as the underneath layer could enable features that aren't possible on reason-react or rescript/react, for example adding missing attributes, allowing destructuring props in bindings, allowing "data"-attributes or type-safety on certain attributes.

This is SO exciting.

Thanks for working on this! 🤝

lib/dom.mli Outdated

module DangerouslySetInnerHTML : sig
(* Need to check the representation in JS of this type, can we have an Obj with a field type-safe in jsoo? *)
type t = { __html: string }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can leave t abstract and use js.builder, e.g.

val make: __html:string -> t
[@@js.builder]

@@ -0,0 +1,584 @@
type attributeType = String | Int | Float | Bool | Style | Ref | InnerHtml
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it would make sense to move this whole module to dom.mli? It seems all of it is part of Dom 🤔

dom.mli is processed by gen_js_api, but it should be easy to move the html.ml contents to it. The way the gen_js_api works allows to stop the preprocessor from processing any code with [@@@js.stop] and start it with [@@@js.start]. It also allows to "send" some code to the resulting ml file with [@@@js.implem]. With these three primitives, we can do everything we need, e.g. if we call the submodule Prop, so we would end up with Dom.Prop:

(* stop gen_js_api preprocessor so that types below are not processed *)
[@@@js.stop]
module Prop: sig
  (* can hide here the types or values that don't need to be public. *)
  type attrType = String | Int | Float | Bool | Style | Ref | InnerHtml
  type eventType = ...
  ...
  val propTypeList: prop list
end

(* Start preprocessing again *)
[@@@js.start]

(* This should go in resulting `.ml` file created by gen_js_api *)
[@@@js.implem
module Prop = struct
  type attrType = String | Int | Float | Bool | Style | Ref | InnerHtml
  type eventType = ...
  ...
  let propTypeList = ...
end ]
(* Now the rest of the file can continue being processed *)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved out of dom.mli since those get exposed on purpose to the user, while the html.ml/i shouldn't be. If makes more sense to keep it inside Dom, we can make a module called... Private__ or somekind to express it better. WDYT?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, I didn't realize the html.ml file was in ppx folder. 👍

Maybe just inline this inside this module in ppx.ml then? So it's clear that is something internal to the ppx.

About the modlue naming, I would use module Dom for consistency with the other one, and with React.js docs. https://reactjs.org/docs/dom-elements.html, as HTML has different attributes than the props passed to these elements.

But these are all nits :)

ppx/ppx.ml Outdated
(label, mapper.expr mapper expression)) )
(* Filtering out the props that don't have a label, not sure how's possible *)
let propsWithName =
List.filter (fun (name, _) -> getLabel name != "") nonEmptyProps
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? Swallowing silently is suspicious 😬

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably the last prop as unit? I'm not sure actually. Will look into it and not merge this until it's "discovered/fixed".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made it more explicit, was the unit

@jchavarri
Copy link
Collaborator

This is a quick analysis of bundle size impact of this PR using webpack-bundle-analyzer.

For each case, I ran:

  • make build-prod from project root folder
  • Then yarn webpack:production from examples folder (previously adding new BundleAnalyzerPlugin() to webpack plugis list.

This branch (84e7869):
image

Master branch (1ff2683):
image

ppx/ppx.ml Outdated
let makePropField (arg_label, value) =
let valueLoc = value.pexp_loc in
let argLabelLoc = unionLocation loc value.pexp_loc in
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I append both locations to ensure the argLabel is part of the error.

Before

File "example/src/Events.re", line 49, characters 6-13:
49 |       <button type_="submit" xxxx=0> {"submit dis" |> React.string} </button>
           ^^^^^^^
Error: prop 'xxxx' isn't a valid React prop

Passing the expression loc

49 |       <button type_="submit" xxxx=0> {"submit dis" |> React.string} </button>
                                       ^
Error: prop 'xxxx' isn't a valid React prop

After (with unioning the locations)

File "example/src/Events.re", lines 1-49, characters 6-35:
-1 |       <button type_="submit" xxxx=0> {"submit dis" |> React.string} </button>
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: prop 'xxxx' isn't a valid React prop

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hope it's clear. We could get fancier: with the length of the argLabel and minus the expr loc minus 1 (the =) maybe can point to the argLabel. Let me know if you think is worth it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! Another idea (for the future ™️ ) could be to print after the error a link to a dedicated page in https://ml-in-barcelona.github.io/jsoo-react/ that explains native elements, and lists the whole list of tags that are allowed (the list could be shared code with the ppx ✨ ).

One nice thing is that because all these checks happen at compile time we don't need to care about the errors or urls making bundle size larger.

Copy link
Collaborator

@jchavarri jchavarri Nov 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I append both locations to ensure the argLabel is part of the error.

Would it be easier to pick the location from the parent ast node? So we avoid having to manipulate locations this way.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, the Pexp_apply one:

image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's way smarter!

Pushed a commit with this change, and appended a small refactor afterwards.

@jchavarri
Copy link
Collaborator

Bringing this in. Thanks again @davesnx !

@jchavarri jchavarri merged commit 1703865 into master Dec 5, 2021
@jchavarri jchavarri deleted the Inline-props-as-object branch December 5, 2021 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Remove superfluous JS output from ReactDOM.domProps
2 participants