From bb19a734295db3fcb0075a2feb4123b6de61812e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Cha=CC=81varri?= Date: Sun, 16 Dec 2018 11:38:42 +0100 Subject: [PATCH 1/3] Add useReducer API --- lib/Reactify.re | 42 +++++++++++++++++++++++++++--------------- lib/Reactify_Types.re | 9 +++++---- lib/State.re | 12 +++++++----- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/lib/Reactify.re b/lib/Reactify.re index 77055b0..b7f30ca 100644 --- a/lib/Reactify.re +++ b/lib/Reactify.re @@ -51,10 +51,7 @@ module Make = (ReconcilerImpl: Reconciler) => { } and t = container and childInstances = list(instance); - - type stateUpdateFunction('t) = 't => unit; - type stateResult('t) = ('t, stateUpdateFunction('t)); - + type node = ReconcilerImpl.node; type primitives = ReconcilerImpl.primitives; @@ -479,18 +476,20 @@ module Make = (ReconcilerImpl: Reconciler) => { newChildInstances^; }; - let useState = (v: 't) => { - let state = __globalState^; - let n = ComponentState.popOldState(state, v); - - let updateFunction = ComponentState.pushNewState(state, n); + let useReducer = + (reducer: ('state, 'action) => 'state, initialState: 'state) => { + let globalState = __globalState^; + let componentState = + ComponentState.popOldState(globalState, initialState); - /* let updateFunction = (_n) => { (); }; */ + let (getState, updateState) = + ComponentState.pushNewState(globalState, componentState); - let currentContext = ComponentState.getCurrentContext(state); + let currentContext = ComponentState.getCurrentContext(globalState); - let setState = (context: ref(option(instance)), newVal: 't) => { - updateFunction(newVal); + let dispatch = (context: ref(option(instance)), action: 'action) => { + let newVal = reducer(getState(), action); + updateState(newVal); switch (context^) { | Some(i) => let {rootNode, component, _} = i; @@ -503,7 +502,20 @@ module Make = (ReconcilerImpl: Reconciler) => { }; }; - (n, setState(currentContext)); + (componentState, dispatch(currentContext)); + }; + + type useStateAction('a) = + | SetState('a); + let useStateReducer = (_state, action) => + switch (action) { + | SetState(newState) => newState + }; + let useState = initialState => { + let (componentState, dispatch) = + useReducer(useStateReducer, initialState); + let setState = newState => dispatch(SetState(newState)); + (componentState, setState); }; let updateContainer = (container, component) => { @@ -520,4 +532,4 @@ module Make = (ReconcilerImpl: Reconciler) => { module State = State; module Event = Event; module Utility = Utility; -module Object = Object; +module Object = Object; \ No newline at end of file diff --git a/lib/Reactify_Types.re b/lib/Reactify_Types.re index 85b93e9..705c97b 100644 --- a/lib/Reactify_Types.re +++ b/lib/Reactify_Types.re @@ -105,7 +105,8 @@ module type React = { let useEffect: (~condition: Effects.effectCondition=?, Effects.effectFunction) => unit; - type stateUpdateFunction('t) = 't => unit; - type stateResult('t) = ('t, stateUpdateFunction('t)); - let useState: 't => stateResult('t); -}; + let useState: 'state => ('state, 'state => unit); + + let useReducer: + (('state, 'action) => 'state, 'state) => ('state, 'action => unit); +}; \ No newline at end of file diff --git a/lib/State.re b/lib/State.re index 67fc0ad..406f799 100644 --- a/lib/State.re +++ b/lib/State.re @@ -26,7 +26,7 @@ module Make = (StateContextImpl: StateContext) => { mutable newState: HeterogenousMutableList.t, }; - type updateFunction('a) = 'a => unit; + type getterAndUpdater('a) = (unit => 'a, 'a => unit); let noneContext = () => ref(None); @@ -51,15 +51,17 @@ module Make = (StateContextImpl: StateContext) => { curr; }; - let pushNewState: (t, 'a) => updateFunction('a) = + let pushNewState: (t, 'a) => getterAndUpdater('a) = (state: t, currentVal: 'a) => { let updatedVal: ref(Object.t) = ref(Object.to_object(currentVal)); state.newState = List.append(state.newState, [updatedVal]); - let ret: updateFunction('a) = + let ret: getterAndUpdater('a) = ( + () => Object.of_object(updatedVal^), (newVal: 'a) => { updatedVal := Object.to_object(newVal); (); - }; + }, + ); ret; }; @@ -67,4 +69,4 @@ module Make = (StateContextImpl: StateContext) => { let getNewState: t => HeterogenousMutableList.t = (state: t) => state.newState; -}; +}; \ No newline at end of file From e9893966e3ef41cb6d901840b2e5a3c30f241a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Cha=CC=81varri?= Date: Sun, 16 Dec 2018 13:30:19 +0100 Subject: [PATCH 2/3] Add tests --- test/HooksUseReducerTest.re | 245 ++++++++++++++++++++++++++++++++++++ test/Test.re | 1 + 2 files changed, 246 insertions(+) create mode 100644 test/HooksUseReducerTest.re diff --git a/test/HooksUseReducerTest.re b/test/HooksUseReducerTest.re new file mode 100644 index 0000000..b19e74c --- /dev/null +++ b/test/HooksUseReducerTest.re @@ -0,0 +1,245 @@ +/** HooksUseReducer **/ +open Rejest; + +open TestReconciler; +open TestUtility; + +module Event = Reactify.Event; + +/* Use our Reconciler to create our own instance */ +module TestReact = Reactify.Make(TestReconciler); +open TestReact; + +let createRootNode = () => {children: ref([]), nodeId: 0, nodeType: Root}; + +let aComponent = (~testVal, ~children, ()) => + primitiveComponent(A(testVal), ~children); +let bComponent = (~children, ()) => primitiveComponent(B, ~children); +let cComponent = (~children, ()) => primitiveComponent(C, ~children); + +type action = + | Increase + | Decrease; +let reducer = (state, action) => + switch (action) { + | Increase => state + 1 + | Decrease => state - 1 + }; + +module ComponentWithState = ( + val component((render, ~children, ()) => + render( + () => { + /* Hooks */ + let (s, _dispatch) = useReducer(reducer, 2); + /* End hooks */ + + ; + }, + ~children, + ) + ) +); + +type renderOption = + /* | Nothing */ + | RenderAComponentWithState + | RenderAComponent(int); + +test("useReducer", () => { + test("useReducer uses initial state", () => { + let rootNode = createRootNode(); + let container = createContainer(rootNode); + + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(2))]); + + updateContainer(container, ); + + validateStructure(rootNode, expectedStructure); + }); + + module ComponentThatDispatchesIncreaseAction = ( + val component( + (render, ~children, ~event: Event.t(unit), ~initialValue: int, ()) => + render( + () => { + /* Hooks */ + let (s, dispatch) = useReducer(reducer, initialValue); + /* End hooks */ + + useEffect(() => { + let unsubscribe = + Event.subscribe(event, () => dispatch(Increase)); + () => unsubscribe(); + }); + + ; + }, + ~children, + ) + ) + ); + + test("useReducer updates state with dispatch function", () => { + let rootNode = createRootNode(); + + let container = createContainer(rootNode); + + let event: Event.t(unit) = Event.create(); + + updateContainer( + container, + , + ); + + Event.dispatch(event, ()); + + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(3))]); + validateStructure(rootNode, expectedStructure); + }); + + test("useReducer doesn't leak state between components", () => { + let rootNode = createRootNode(); + + let container = createContainer(rootNode); + + let event: Event.t(unit) = Event.create(); + + updateContainer( + container, + , + ); + + Event.dispatch(event, ()); + + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(3))]); + validateStructure(rootNode, expectedStructure); + + updateContainer(container, ); + + /* The 'componentWithState' should have its own state, so it should revert back to 2 - */ + /* and not pick up the state from the previous component */ + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(2))]); + validateStructure(rootNode, expectedStructure); + }); + + test("useReducer dispatch state persists across renders", () => { + let rootNode = createRootNode(); + + let container = createContainer(rootNode); + + let event: Event.t(unit) = Event.create(); + + updateContainer( + container, + , + ); + + Event.dispatch(event, ()); + + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(3))]); + validateStructure(rootNode, expectedStructure); + + updateContainer( + container, + , + ); + + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(3))]); + validateStructure(rootNode, expectedStructure); + }); + + test("useReducer can update multiple times", () => { + let rootNode = createRootNode(); + let container = createContainer(rootNode); + + let event: Event.t(unit) = Event.create(); + + updateContainer( + container, + , + ); + + Event.dispatch(event, ()); + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(3))]); + validateStructure(rootNode, expectedStructure); + + Event.dispatch(event, ()); + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(4))]); + validateStructure(rootNode, expectedStructure); + + Event.dispatch(event, ()); + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeLeaf(A(5))]); + validateStructure(rootNode, expectedStructure); + }); + + module ComponentThatDispatchesIncreaseActionAndRendersChildren = ( + val component( + (render, ~children, ~event: Event.t(unit), ~initialValue: int, ()) => + render( + () => { + /* Hooks */ + let (s, dispatch) = useReducer(reducer, initialValue); + + useEffect(() => { + let unsubscribe = + Event.subscribe(event, () => dispatch(Increase)); + () => unsubscribe(); + }); + /* End Hooks */ + + ...children ; + }, + ~children, + ) + ) + ); + + test("nested state works as expected", () => { + let rootNode = createRootNode(); + let container = createContainer(rootNode); + + let outerEvent = Event.create(); + let innerEvent = Event.create(); + + updateContainer( + container, + + + , + ); + + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeNode(A(2), [TreeLeaf(A(12))])]); + validateStructure(rootNode, expectedStructure); + + Event.dispatch(outerEvent, ()); + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeNode(A(3), [TreeLeaf(A(12))])]); + validateStructure(rootNode, expectedStructure); + + Event.dispatch(innerEvent, ()); + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeNode(A(3), [TreeLeaf(A(13))])]); + validateStructure(rootNode, expectedStructure); + + Event.dispatch(outerEvent, ()); + let expectedStructure: tree(primitives) = + TreeNode(Root, [TreeNode(A(4), [TreeLeaf(A(13))])]); + validateStructure(rootNode, expectedStructure); + }); + +}); \ No newline at end of file diff --git a/test/Test.re b/test/Test.re index 154709d..9e25ab2 100644 --- a/test/Test.re +++ b/test/Test.re @@ -6,6 +6,7 @@ module StateTest = StateTest; module PrimitiveComponentTest = PrimitiveComponentTest; module StatelessComponentTest = StatelessComponentTest; module HooksUseEffectTest = HooksUseEffectTest; +module HooksUseReducerTest = HooksUseReducerTest; module HooksUseStateTest = HooksUseStateTest; module HooksUseContextTest = HooksUseContextTest; module UtilityTest = UtilityTest; From 60e270060db3980a833d4a62b5750c7b5e540855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Cha=CC=81varri?= Date: Sun, 16 Dec 2018 22:43:40 +0100 Subject: [PATCH 3/3] Adding useReducer to examples --- examples/dom/WebReconciler.re | 93 +++++++++++++++++++++++------ examples/dom/index.html | 2 +- examples/lambda-term/Lambda_term.re | 40 ++++++++----- lib/Reactify.re | 4 +- 4 files changed, 103 insertions(+), 36 deletions(-) diff --git a/examples/dom/WebReconciler.re b/examples/dom/WebReconciler.re index 83b8c03..86f7ea3 100644 --- a/examples/dom/WebReconciler.re +++ b/examples/dom/WebReconciler.re @@ -5,16 +5,20 @@ * This is just an example but you could use this to create interesting * CLI apps, with a react-like functional API! */ + +exception InvalidNodePrimitiveMatchInUpdateInstance; + +let str = string_of_int; + module Reconciler = { /* Step 1: Define primitives */ - type imageProps = {src: string}; - type buttonProps = {src: string}; type primitives = - | Div - | Span(string) - | Image(imageProps); + | View + | Text(string) + | Image(string) /* img src */ + | Button(unit => unit, string); /* onPress, title */ /* Step 2: Define node type @@ -23,6 +27,7 @@ module Reconciler = { | Div(Js.t(Dom_html.divElement)) | Span(Js.t(Dom_html.element)) | Image(Js.t(Dom_html.imageElement)) + | Button(Js.t(Dom_html.buttonElement)) | Container(Js.t(Dom_html.element)); let document = Dom_html.window##.document; @@ -32,15 +37,27 @@ module Reconciler = { let createInstance: primitives => node = primitive => switch (primitive) { - | Div => Div(Dom_html.createDiv(document)) - | Span(s) => + | View => Div(Dom_html.createDiv(document)) + | Text(s) => let e = Dom_html.createSpan(document); e##.innerHTML := Js.string(s); Span(e); | Image(p) => let img = Dom_html.createImg(document); - img##.src := Js.string(p.src); + img##.src := Js.string(p); Image(img); + | Button(onPress, title) => + let button = + Dom_html.createButton(~_type=Js.string("button"), document); + let t = Js.string(title); + button##.title := t; + button##.innerHTML := t; + button##.onclick := + Dom_html.handler(_e => { + onPress(); + Js.bool(false); + }); + Button(button); }; /* @@ -52,15 +69,26 @@ module Reconciler = { | Div(e) => e |> Dom_html.element | Span(e) => e |> Dom_html.element | Image(e) => e |> Dom_html.element + | Button(e) => e |> Dom_html.element | Container(e) => e |> Dom_html.element }; let updateInstance = (node: node, _oldPrimitive: primitives, newPrimitive: primitives) => switch (newPrimitive, node) { - /* The only update operation we handle today is updating src for an image! */ - | (Image({src}), Image(e)) => e##.src := Js.string(src) - | _ => () + | (View, Div(_e)) => () + | (Text(s), Span(e)) => e##.innerHTML := Js.string(s) + | (Image(src), Image(e)) => e##.src := Js.string(src) + | (Button(onPress, title), Button(e)) => + let t = Js.string(title); + e##.title := t; + e##.innerHTML := t; + e##.onclick := + Dom_html.handler(_e => { + onPress(); + Js.bool(false); + }); + | _ => raise(InvalidNodePrimitiveMatchInUpdateInstance) }; let appendChild = (parentNode: node, childNode: node) => { @@ -69,6 +97,7 @@ module Reconciler = { | Div(e) => Dom.appendChild(e, innerNode) | Span(e) => Dom.appendChild(e, innerNode) | Image(e) => Dom.appendChild(e, innerNode) + | Button(e) => Dom.appendChild(e, innerNode) | Container(e) => Dom.appendChild(e, innerNode) }; }; @@ -79,6 +108,7 @@ module Reconciler = { | Div(e) => Dom.removeChild(e, innerNode) | Span(e) => Dom.removeChild(e, innerNode) | Image(e) => Dom.removeChild(e, innerNode) + | Button(e) => Dom.removeChild(e, innerNode) | Container(e) => Dom.removeChild(e, innerNode) }; }; @@ -90,6 +120,7 @@ module Reconciler = { | Div(e) => Dom.replaceChild(e, newInnerNode, oldInnerNode) | Span(e) => Dom.replaceChild(e, newInnerNode, oldInnerNode) | Image(e) => Dom.replaceChild(e, newInnerNode, oldInnerNode) + | Button(e) => Dom.replaceChild(e, newInnerNode, oldInnerNode) | Container(e) => Dom.replaceChild(e, newInnerNode, oldInnerNode) }; }; @@ -97,16 +128,44 @@ module Reconciler = { /* Step 5: Hook it up! */ module JsooReact = Reactify.Make(Reconciler); +open JsooReact; /* Define our primitive components */ -let div = (~children, ()) => JsooReact.primitiveComponent(Div, ~children); - -let span = (~text, ~children, ()) => - JsooReact.primitiveComponent(Span(text), ~children); +let view = (~children, ()) => JsooReact.primitiveComponent(View, ~children); -let image = (~children, ~src, ()) => +let image = (~children, ~src="", ()) => JsooReact.primitiveComponent(Image(src), ~children); +let text = (~children: list(string), ()) => + JsooReact.primitiveComponent(Text(List.hd(children)), ~children=[]); + +let button = (~children, ~onPress, ~title, ()) => + JsooReact.primitiveComponent(Button(onPress, title), ~children); + +type action = + | Increment + | Decrement; + +let reducer = (state, action) => + switch (action) { + | Increment => state + 1 + | Decrement => state - 1 + }; + +let renderCounter = () => { + let (count, dispatch) = useReducer(reducer, 0); + + +