Skip to content

Commit

Permalink
Make ReactElement a proper Monoid and other ergonomic tweaks (#84)
Browse files Browse the repository at this point in the history
  • Loading branch information
fsoikin authored Jul 29, 2024
1 parent 17e826a commit 51d08ce
Show file tree
Hide file tree
Showing 19 changed files with 268 additions and 89 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.13.0

### Changed
* `ReactElement` is now a full `Monoid` with `empty` as identity and append
creating React.Fragment elements.
* **Breaking**: module `Elmish.React.DOM` has been removed and its contents
moved to `Elmish.React`.
* **Breaking**: module `Elmish.Trace` has been removed. Its sole export has been
part of the standard `debug` library for a while now.
* Fixed a bug with `readForeign` and nested `Nullable`s: reading `[1,"foo",2]`
as `Nullable (Array Int)` would complain that the second element is bogus
(which is true) and incorrectly state that the expected type was `Nullable Int`.

## 0.12.0

### Changed
Expand Down
62 changes: 58 additions & 4 deletions docs/react-ffi.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,63 @@ nav_order: 6
# Using React components
{:.no_toc}

> **Under construction**. This page is unfinished. Many headings just have some
> bullet points sketching the main points that should be discussed.
Because Elmish is just a thin layer on top of React, it is quite easy to use
non-PureScript React components from the wider ecosystem.

1. TOC
{:toc}
A typical import of a React component consists of three parts:

* A row of props, with optional props denoted via `Opt`.
* Actual FFI-import of the component constructor. This import is weakly
typed and shouldn't be exported from the module. Consider it internal
implementation detail.
* Strongly-typed, PureScript-friendly function that constructs the
component. The body of such function usually consists of just a call
to `createElement` (or `createElement'` for childless components), its
only purpose being the type signature. This function is what should be
exported for use by consumers.

Classes and type aliases provided in this module, when applied to the
constructor function, make it possible to pass only partial props to it,
while still ensuring their correct types and presence of non-optional ones.
This is facilitated by the
[undefined-is-not-a-problem](https://github.com/paluh/purescript-undefined-is-not-a-problem/) library.

## Example

### The JSX file with component implementation
```jsx
// `world` prop is required, `hello` and `highlight` are optional
export const MyComponent = ({ hello, world, highlight }) =>
<div>
<span>{hello || "Hello"}, </span>
<span style={ { color: highlight ? "red" : "" } }>{world}</span>
</div>
```

### PureScript FFI module
```haskell
module MyComponent(Props, myComponent) where

import Data.Undefined.NoProblem (Opt)
import Elmish.React (createElement)
import Elmish.React.Import (ImportedReactComponentConstructor, ImportedReactComponent)

type Props = ( world :: String, hello :: Opt String, highlight :: Opt Boolean )

myComponent :: ImportedReactComponentConstructor Props
myComponent = createElement myComponent_

foreign import myComponent_ :: ImportedReactComponent
```

### PureScript use site
```haskell
import MyComponent (myComponent)
import Elmish.React (fragment) as H

view :: ...
view = H.fragment
[ myComponent { world: "world" }
, myComponent { hello: "Goodbye", world: "cruel world!", highlight: true }
]
```
2 changes: 1 addition & 1 deletion packages.dhall
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ in upstream
with elmish-html =
{ dependencies = [ "prelude", "record" ]
, repo = "https://github.com/collegevine/purescript-elmish-html.git"
, version = "v0.8.2"
, version = "tweaks"
}
20 changes: 10 additions & 10 deletions src/Elmish/Component.purs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ import Effect (Effect, foreachE)
import Effect.Aff (Aff, Milliseconds(..), delay, launchAff_)
import Effect.Class (class MonadEffect, liftEffect)
import Elmish.Dispatch (Dispatch)
import Elmish.React (ReactComponent, ReactComponentInstance, ReactElement, getField, setField)
import Elmish.React (ReactComponent, ReactComponentInstance, ReactElement)
import Elmish.React.Internal (Field(..), getField, setField)
import Elmish.State (StateStrategy, dedicatedStorage, localState)
import Elmish.Trace (traceTime)

-- | A UI component state transition: wraps the new state value together with a
-- | (possibly empty) list of effects that the transition has caused (called
Expand Down Expand Up @@ -243,10 +243,10 @@ withTrace :: ∀ m msg state
withTrace def = def { update = tracingUpdate, view = tracingView }
where
tracingUpdate s m =
let (Transition s cmds) = traceTime "Update" \_ -> def.update s $ Debug.spy "Message" m
let (Transition s cmds) = Debug.traceTime "Update" \_ -> def.update s $ Debug.spy "Message" m
in Transition (Debug.spy "State" s) cmds
tracingView s d =
traceTime "Rendering" \_ -> def.view s d
Debug.traceTime "Rendering" \_ -> def.view s d

-- | This function is low level, not intended for a use in typical consumer
-- | code. Use `construct` or `wrapWithLocalState` instead.
Expand Down Expand Up @@ -307,13 +307,13 @@ bindComponent cmpt stateStrategy = \def -> -- Explicit lambda to make sure `def`
sequence_ =<< getSubscriptions component
setSubscriptions [] component

subscriptionsField = "__subscriptions"
getSubscriptions = getField @(Array (Effect Unit)) subscriptionsField >>> map (fromMaybe [])
setSubscriptions = setField @(Array (Effect Unit)) subscriptionsField
subscriptionsField = Field @"__subscriptions" @(Array (Effect Unit))
getSubscriptions = getField subscriptionsField >>> map (fromMaybe [])
setSubscriptions = setField subscriptionsField

unmountedField = "__unmounted"
getUnmounted = getField @Boolean unmountedField >>> map (fromMaybe false)
setUnmounted = setField @Boolean unmountedField
unmountedField = Field @"__unmounted" @Boolean
getUnmounted = getField unmountedField >>> map (fromMaybe false)
setUnmounted = setField unmountedField

-- | Given a `ComponentDef'`, binds that def to a freshly created React class,
-- | instantiates that class, and returns a rendering function.
Expand Down
2 changes: 1 addition & 1 deletion src/Elmish/Foreign.purs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ instance CanReceiveFromJavaScript a => CanReceiveFromJavaScript (Nullable a) whe
| otherwise =
case validateForeignType @a v of
Valid -> Valid
Invalid err -> Invalid err { expected = "Nullable " <> err.expected }
Invalid err -> Invalid err { expected = if err.path == "" then "Nullable " <> err.expected else err.expected }

instance CanPassToJavaScript a => CanPassToJavaScript (Opt a)
instance CanReceiveFromJavaScript a => CanReceiveFromJavaScript (Opt a) where
Expand Down
17 changes: 17 additions & 0 deletions src/Elmish/React.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ export var hydrate_ = ReactDOM.hydrate;
export var renderToString = (ReactDOMServer && ReactDOMServer.renderToString) || (_ => "");
export var unmount_ = ReactDOM.unmountComponentAtNode

export var fragment_ = React.Fragment;

export var appendElement_ = a => b => {
const childrenOf = x => {
if (x === false || x === null || typeof x === 'undefined') return []
if (x.type === React.Fragment) {
const children = x.props?.children
if (children instanceof Array) return children
if (children === false || children === null || typeof children === 'undefined') return []
return [children]
}
return [x]
}
const allChildren = [...childrenOf(a), ...childrenOf(b)]
return allChildren.length === 0 ? false : React.createElement(React.Fragment, null, allChildren)
}

export function createElement_(component, props, children) {
// The type of `children` is `Array ReactElement`. If we pass that in as
// third parameter of `React.createElement` directly, React complains about
Expand Down
65 changes: 37 additions & 28 deletions src/Elmish/React.purs
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
module Elmish.React
( ReactElement
, ReactComponent
, ReactComponentInstance
, class ValidReactProps
, class ReactChildren, asReactChildren
, assignState
, createElement
, createElement'
, getField
, getState
, hydrate
, setField
, setState
, render
, renderToString
, unmount
, module Ref
) where
( ReactElement
, ReactComponent
, ReactComponentInstance
, class ValidReactProps
, class ReactChildren, asReactChildren
, assignState
, createElement
, createElement'
, empty
, fragment
, getState
, hydrate
, setState
, render
, renderToString
, text
, unmount
, module Ref
) where

import Prelude

import Data.Function.Uncurried (Fn3, runFn3)
import Data.Maybe (Maybe)
import Data.Nullable (Nullable)
import Effect (Effect)
import Effect.Uncurried (EffectFn1, EffectFn2, EffectFn3, runEffectFn1, runEffectFn2, runEffectFn3)
import Elmish.Foreign (class CanPassToJavaScript, class CanReceiveFromJavaScript, Foreign, readForeign)
import Elmish.Foreign (class CanPassToJavaScript)
import Elmish.React.Ref (Ref, callbackRef) as Ref
import Prim.TypeError (Text, class Fail)
import Unsafe.Coerce (unsafeCoerce)
Expand All @@ -34,6 +34,9 @@ import Web.DOM as HTML
-- | Instantiated subtree of React DOM. JSX syntax produces values of this type.
foreign import data ReactElement :: Type

instance Semigroup ReactElement where append = appendElement_
instance Monoid ReactElement where mempty = empty

-- | This type represents constructor of a React component with a particular
-- | behavior. The type prameter is the record of props (in React lingo) that
-- | this component expects. Such constructors can be "rendered" into
Expand Down Expand Up @@ -82,6 +85,20 @@ createElement' :: ∀ props
-> ReactElement
createElement' component props = createElement component props ([] :: Array ReactElement)

-- | Empty React element.
empty :: ReactElement
empty = unsafeCoerce false

-- | Render a plain string as a React element.
text :: String -> ReactElement
text = unsafeCoerce

-- | Wraps multiple React elements as a single one (import of React.Fragment)
fragment :: Array ReactElement -> ReactElement
fragment = createElement fragment_ {}

foreign import fragment_ :: ReactComponent {}
foreign import appendElement_ :: ReactElement -> ReactElement -> ReactElement

-- | Asserts that the given type is a valid React props structure. Currently
-- | there are three rules for what is considered "valid":
Expand Down Expand Up @@ -112,14 +129,6 @@ setState :: ∀ state. ReactComponentInstance -> state -> (Effect Unit) -> Effec
setState = runEffectFn3 setState_
foreign import setState_ :: state. EffectFn3 ReactComponentInstance state (Effect Unit) Unit

getField :: @a. CanReceiveFromJavaScript a => String -> ReactComponentInstance -> Effect (Maybe a)
getField field object = runEffectFn2 getField_ field object <#> readForeign @a
foreign import getField_ :: EffectFn2 String ReactComponentInstance Foreign

setField :: @a. CanPassToJavaScript a => String -> a -> ReactComponentInstance -> Effect Unit
setField = runEffectFn3 setField_
foreign import setField_ :: a. EffectFn3 String a ReactComponentInstance Unit

-- | The equivalent of `this.state = x`, as opposed to `setState`, which is the
-- | equivalent of `this.setState(x)`. This function is used in a component's
-- | constructor to set the initial state.
Expand Down
2 changes: 0 additions & 2 deletions src/Elmish/React/DOM.js

This file was deleted.

22 changes: 0 additions & 22 deletions src/Elmish/React/DOM.purs

This file was deleted.

6 changes: 3 additions & 3 deletions src/Elmish/React/Import.purs
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,23 @@
-- |
-- |
-- | -- PureScript
-- | module MyComponent(Props, OptProps, myComponent) where
-- | module MyComponent(Props, myComponent) where
-- |
-- | import Data.Undefined.NoProblem (Opt)
-- | import Elmish.React (createElement)
-- | import Elmish.React.Import (ImportedReactComponentConstructor, ImportedReactComponent)
-- |
-- | type Props = ( world :: String, hello :: Opt String, highlight :: Opt Boolean )
-- |
-- | myComponent :: ImportedReactComponentConstructor Props OptProps
-- | myComponent :: ImportedReactComponentConstructor Props
-- | myComponent = createElement myComponent_
-- |
-- | foreign import myComponent_ :: ImportedReactComponent
-- |
-- |
-- | -- PureScript use site
-- | import MyComponent (myComponent)
-- | import Elmish.React.DOM (fragment)
-- | import Elmish.React (fragment) as H
-- |
-- | view :: ...
-- | view = H.fragment
Expand Down
2 changes: 2 additions & 0 deletions src/Elmish/React/Internal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const getField_ = (field, obj) => obj[field]
export const setField_ = (field, value, obj) => obj[field] = value
25 changes: 25 additions & 0 deletions src/Elmish/React/Internal.purs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module Elmish.React.Internal
( Field(..)
, getField
, setField
) where

import Prelude

import Data.Maybe (Maybe)
import Data.Symbol (class IsSymbol, reflectSymbol)
import Effect (Effect)
import Effect.Uncurried (EffectFn2, EffectFn3, runEffectFn2, runEffectFn3)
import Elmish.Foreign (class CanPassToJavaScript, class CanReceiveFromJavaScript, Foreign, readForeign)
import Elmish.React (ReactComponentInstance)
import Type.Proxy (Proxy(..))

data Field (f :: Symbol) (a :: Type) = Field

getField :: f a. CanReceiveFromJavaScript a => IsSymbol f => Field f a -> ReactComponentInstance -> Effect (Maybe a)
getField _ object = runEffectFn2 getField_ (reflectSymbol $ Proxy @f) object <#> readForeign @a
foreign import getField_ :: EffectFn2 String ReactComponentInstance Foreign

setField :: f a. CanPassToJavaScript a => IsSymbol f => Field f a -> a -> ReactComponentInstance -> Effect Unit
setField _ = runEffectFn3 setField_ $ reflectSymbol $ Proxy @f
foreign import setField_ :: a. EffectFn3 String a ReactComponentInstance Unit
6 changes: 6 additions & 0 deletions src/Elmish/React/Ref.purs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ instance CanPassToJavaScript (Ref a)
-- | view :: State -> Dispatch Message -> ReactElement
-- | view state dispatch =
-- | H.input_ "" { ref: callbackRef state.inputElement (dispatch <<< RefChanged), … }
-- |
-- | update :: State -> Message -> Transition Message State
-- | update state = case _ of
-- | RefChanged ref -> pure state { inputElement = ref }
-- |
-- | ```
-- |
callbackRef :: forall el. Maybe el -> (Maybe el -> Effect Unit) -> Ref el
callbackRef ref setRef = mkCallbackRef $ mkEffectFn1 \ref' -> case ref, Nullable.toMaybe ref' of
Nothing, Nothing -> pure unit
Expand Down
10 changes: 0 additions & 10 deletions src/Elmish/Trace.js

This file was deleted.

7 changes: 0 additions & 7 deletions src/Elmish/Trace.purs

This file was deleted.

Loading

0 comments on commit 51d08ce

Please sign in to comment.