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

Replace old ReasonReact-like API with Hooks #27

Merged
merged 24 commits into from
Jan 17, 2019
Merged

Conversation

wokalski
Copy link
Member

@wokalski wokalski commented Jan 12, 2019

This PR introduces basic support for a React-Hook like API. The only supported hooks are useState and useReducer. Implementing useEffect will probably require some architectural overhaul to the current naive Slots approach. Also, there's a lot
of mutation everywhere now. It'd be great to minimise this in the next version.

Temporary component API

If you look at the tests the component looks like this:

let component = component(debugName);
let make = (~prop, children) => component(slots => { ... });
let createElement = ...

This API is temporary because it doesn't require a PPX for JSX. The planned API is:

let componentName = component((slots, ~prop, ~prop2, ()) => { ... })

@wokalski wokalski added the core Core label Jan 12, 2019
core/lib/Slots.re Outdated Show resolved Hide resolved
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.

This looks amazing @wokalski !! It's really great to see how hooks simplify many parts of the code. I was also surprised by how the implementation of hooks in Hooks.re takes just a few lines 😮

I had just a few questions, that maybe others reviewing the PR could also share.

core/lib/Hooks.re Outdated Show resolved Hide resolved
core/lib/RemoteAction.rei Show resolved Hide resolved
core/lib/ReactCore_Internal.re Show resolved Hide resolved
core/lib/Slots.re Outdated Show resolved Hide resolved
tester-macos/bin/app.re Outdated Show resolved Hide resolved
@wokalski
Copy link
Member Author

@jchavarri do you have any ideas how to implement a PPX rewriter for this hook API?

@jchavarri
Copy link
Collaborator

jchavarri commented Jan 12, 2019

@wokalski I had a wip branch called hooks-ppx in reason-reactify: jchavarri/reason-reactify@master...jchavarri:hooks-ppx

Note this ppx was highly experimental, and it was applied to the continuation-passing style API we explored at some point. But it was working for that case (at least the happy path), and it might help as starting point for the slots ppx.

Relevant files: ppx/dune, ppx/hook.re and the package.json for the two deps (ocaml-migrate-parsetree and ppx-tools).

One thing I was also planning to add are some tests for it. I was taking a look at a section called "Testing the rewriter" in http://rgrinberg.com/posts/extension-points-3-years-later/#testing-the-rewriter.

@wokalski
Copy link
Member Author

@jchavarri i see. My main conceptual problem with the ppx is how do we pass the initial slots value

@jchavarri
Copy link
Collaborator

@wokalski One would need two ppxs: one for the component and one for the hook.

For example:

let%comp counter = (_children) => {
  let%hook (counter, setState) = Hooks.useState(0);
  <Button onClick={() => setState(counter + 1)} />;
};

or with a module ppx, which is maybe more "legit" about what is happening behind the scenes, but still can alleviate all the work of manually calling component, creating make using it, and adding createElement:

module%comp Counter = {
  let render = _children => {
    let%hook (counter, setState) = Hooks.useState(0);
    <Button onClick={() => setState(counter + 1)} />;
  }
};

both would be transformed into:

module Counter = {
  let component = component("Counter");
  let make = _children =>
    component(_slots => {
      let (counter, setState, _slots) = Hooks.useState(0, _slots);
      <Button onClick={() => setState(counter + 1)} />;
    });
  let createElement = (~children, ()) => element(make(children));
};

The %comp ppx could hide more or less surface from the underlying API.

@wokalski
Copy link
Member Author

wokalski commented Jan 13, 2019

@jchavarri great! I think we're on the same page, I've also been thinking about such PPX! I'm not sure what should be the "custom" hook ppx. I.e. how to name it. The bonus point of a ppx is that we can use an invalid ocaml identifier as the variable name - it's very unlikely for it to shadow anything in such case!

@jchavarri
Copy link
Collaborator

jchavarri commented Jan 14, 2019

I'm not sure what should be the "custom" hook ppx

I guess you mean "custom" component ppx? Maybe we could use let%render or module%render, as "render" is kind of a universal React term for that? The downsides I see from the let% ppx (as opposed to the module% one) are:

  • the lowercase/uppercase issue: let statements only allow lowercase identifiers, but uppercase components are the common idiomatic approach in ReactJS. That's why I feel more inclined to use the module% ppx (although it's more verbose 🤔 )
  • module% is more transparent / honest about what's happening behind the scenes

The bonus point of a ppx is that we can use an invalid ocaml identifier as the variable name

For the slots identifier, right? That's a great point! Using an invalid identifier removes all chances of collisions 💯

@wokalski
Copy link
Member Author

@jchavarri I meant the custom hook, ie custom hook function. I realise it's "just a function" but if we use a special identifier for let%hook there has to be a ppx for the definition of such function to provide the hook arg with the same identifier!

When it comes to the component ppx I guess we have to see. I'm more inclined towards lower-case components because they are less verbose and maybe we could leave the upper case PPX for something else!

@jchavarri
Copy link
Collaborator

there has to be a ppx for the definition of such function to provide the hook arg with the same identifier

What do you mean? Wouldn't the author of the custom hook decide whatever name for slots they prefer? The only constraint that I see is that the slots param needs to be the last one.

@wokalski
Copy link
Member Author

@jchavarri if let%component c = (~prop, ()) => { ... } desugars to let c = component((@@hooks, ~prop, ()) => { ... } then let%hook x = useState(prop) has to desugar to let (x, @@hooks) = useState(prop, @@hooks). I.e. the unique "invalid" identifier that let%hook picks up has to be consistent. So, when you're making a "custom" hook, you have to somehow supply the hook with this unique variable name.

@jchavarri
Copy link
Collaborator

the unique "invalid" identifier that let%hook picks up has to be consistent.

I agree. 👍

Maybe I was confused because I wasn't thinking custom hooks authors would use the let%hook ppx, I see that ppx useful for consumers of hooks, not creators.

i.e. I was thinking something like this:

/* Some custom hook, doesn't use any ppx */
let useCustomHook = (prop, slots) => { let foo = useState(prop, slots); ... }

let%component c = (~prop, ()) => { let%hook x = useCustomHook(prop) }

core/lib/Slots.rei Outdated Show resolved Hide resolved
core/lib/Slots.re Outdated Show resolved Hide resolved
@wokalski
Copy link
Member Author

The data structure used for accumulating enqueued effects is extremely inefficient and there are almost no tests for the effect hook. That said I would like to address those issues in a separate PR. I would love to, however, get some feedback on the implementation of hooks themselves and what you think about the API. I think the most relevant modules are: Slots, Hooks, and ReactCore.rei. Please keep in mind that we have not implemented a PPX for the JSX yet but it's high on our priority list.


let executeSideEffects = ({renderedElement} as testState) => {
RenderedElement.executeHostViewUpdates(renderedElement) |> ignore;
List.iter(f => f(), renderedElement.enqueuedEffects);
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like enqueuedEffects isn't available on the renderedElement via ReactCore.

Is it expected that these effects would be ran as part of executeHostViewUpdates? Or will there be a separate API, like executeEffects?

Copy link
Member Author

Choose a reason for hiding this comment

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

There has to be a separate API. The reason is that we normally want to call executeHostViewUpdates on the main thread but the effects should be executed on a background one.

@bryphe
Copy link
Contributor

bryphe commented Jan 17, 2019

The data structure used for accumulating enqueued effects is extremely inefficient and there are almost no tests for the effect hook. That said I would like to address those issues in a separate PR.

Makes sense. The 'Effect' API looks good to me, aside from one comment I left - I'm wondering if there will be a separate API for executing the effects, or if its expected to occur as part of executeHostViewUpdates?

Aside from that - seems like the API surface will be the same, and its just about fixing bugs / streamlining the syntax with PPX / etc. 👍

Thanks for all your hard work on this @wokalski !

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.

This looks awesome @wokalski !! 👏 👏 👏

@@ -0,0 +1,266 @@
type hook('a) = ..;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just curious, what's the advantage of the "open variant" approach? as opposed to define hook('a) after the modules?

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 don't know... yet. I thought it might be an interesting idea to implement hooks as an open variant to allow users creating custom types of hooks in the userspace. Like what if we exposed enough internals (but not too much!) to implement Hooks.suspense in the user space? I think it could be very interesting.

stateContainer := {currentValue, updates: [nextUpdate, ...updates]};
};

let hook = (~initialState, reducer, hooks: hooks(_)) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: initialState is not a named arg in Ref and State.

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 on purpose. Ref and State are single argument + hook so it's pretty self explanatory and for the reducer you know you have to put the reducer in but you might forget the order for instance so we add this explicit ~initialState argument.

onSlotsDidChange,
};

module State = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

As a fun idea, in reason-reactify we used to implement useState as a special case of useReducer which allowed to reduce some of the boilerplate. Not sure if it'd be interesting in this case :)

https://github.com/revery-ui/reason-reactify/blob/38e59a7726146bdb63e4b53bb3068bab6132aa95/lib/Reactify.re#L565-L577

Copy link
Member Author

Choose a reason for hiding this comment

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

they were implemented differently on purpose. My thought process is that the operation in the reducer might take relatively long to compute and actions are very often fired during interactions. Enqueuing an update takes constant time. Also, with this approach it'll be relatively easy to implement UpdateWithSideEffects. That said I'm not sure about this myself 😕

let enqueueUpdate = (nextUpdate, stateContainer) => {
let {currentValue, updates} = stateContainer^;

stateContainer := {currentValue, updates: [nextUpdate, ...updates]};
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why does Reducer follow a different strategy than State? Would it be possible to instead of a list of updates, call the reducer and store the value in nextValue as the state hook does?

| Update;
type always;
type onMount;
type condition('a) =
Copy link
Collaborator

Choose a reason for hiding this comment

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

Trying to wrap my head around this... if this is already a GADT, does the 'a need to be exposed? Would something like this be possible?

type condition =
    | Always: condition
    | OnMount: condition
    | If(('a, 'a) => bool, 'a): condition;

Or alternatively, make it a regular variant / ADT.

I guess I'm not understanding why this is a GADT 😄

Copy link
Member Author

Choose a reason for hiding this comment

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

Good question. The reason is that I want the constructor to carry more type information so that you cannot do this:

Hooks.effect(someProp ? Always : OnMount, () => None)

Always and OnMount have different types so the semantics here would be very confusing!

Copy link
Member Author

Choose a reason for hiding this comment

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

also, for the if condition we have to know what type the compared value is!


module type S = {
type elem('a);
type opaqueElement =
Copy link
Collaborator

Choose a reason for hiding this comment

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

I got slightly confused about element. It's not the reconciler "element", right? This would be more the "content" or the "stored data type" the slots will contain? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

great suggestion!

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed!
cc24a0a

@wokalski wokalski merged commit e1ee4a3 into master Jan 17, 2019
@wokalski wokalski deleted the feature/hooks-api branch January 17, 2019 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core Core
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants