diff --git a/.gitignore b/.gitignore index 7ebb89b..f9fa4f1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /.psc* /.purs* /.psa* +/.vscode/ diff --git a/bower.json b/bower.json index 182b0f3..d0a28e5 100644 --- a/bower.json +++ b/bower.json @@ -1,29 +1,25 @@ { "name": "purescript-react-basic", "license": "Apache-2.0", - "ignore": [ - "**/.*", - "node_modules", - "bower_components", - "output" - ], + "ignore": ["**/.*", "node_modules", "bower_components", "output"], "repository": { "type": "git", "url": "git://github.com/lumihq/purescript-react-basic.git" }, "dependencies": { + "purescript-aff": "^5.0.2", + "purescript-console": "^4.1.0", + "purescript-effect": "^2.0.0", + "purescript-exceptions": "^4.0.0", "purescript-functions": "^4.0.0", - "purescript-unsafe-coerce": "^4.0.0", - "purescript-nullable": "^4.0.0", - "purescript-typelevel-prelude": "^3.0.0", + "purescript-nullable": "^4.1.0", "purescript-record": "^1.0.0", - "purescript-effect": "^2.0.0", - "purescript-web-events": "^1.0.0", + "purescript-typelevel-prelude": "^3.0.0", + "purescript-unsafe-coerce": "^4.0.0", "purescript-web-dom": "^1.0.0", - "purescript-exceptions": "^4.0.0" + "purescript-web-events": "^1.0.0" }, "devDependencies": { - "purescript-web-html": "^1.0.0", - "purescript-console": "^4.1.0" + "purescript-web-html": "^1.0.0" } } diff --git a/codegen/index.js b/codegen/index.js index 98a0e9d..88aab99 100644 --- a/codegen/index.js +++ b/codegen/index.js @@ -1,5 +1,5 @@ -const fs = require('fs'); -const { props, voids, types, reserved } = require('./consts'); +const fs = require("fs"); +const { props, voids, types, reserved } = require("./consts"); const genFile = "../src/React/Basic/DOM/Generated.purs"; const header = `-- | ---------------------------------------- @@ -15,14 +15,15 @@ import React.Basic.Events (EventHandler) `; -const printRecord = (elProps) => elProps.length ? ` - ( ${ elProps.map((p) => - `${p} :: ${types[p] || "String"}`).join("\n , ") - } - )` : "()" +const printRecord = elProps => + elProps.length + ? ` + ( ${elProps.map(p => `${p} :: ${types[p] || "String"}`).join("\n , ")} + )` + : "()"; const domTypes = props.elements.html - .map((e) => { + .map(e => { const noChildren = voids.includes(e); const symbol = reserved.includes(e) ? `${e}'` : e; return ` @@ -36,13 +37,17 @@ const domTypes = props.elements.html => Record attrs -> JSX ${symbol} = element (unsafeCreateDOMComponent "${e}")${ - noChildren ? "" : ` + noChildren + ? "" + : ` ${e}_ :: Array JSX -> JSX ${e}_ children = ${symbol} { children }` } `; -}).map((x) => x.replace(/^\n\ {4}/, "").replace(/\n\ {4}/g, "\n")).join("\n"); + }) + .map(x => x.replace(/^\n\ {4}/, "").replace(/\n\ {4}/g, "\n")) + .join("\n"); console.log(`Writing "${genFile}" ...`); fs.writeFileSync(genFile, header + domTypes); diff --git a/examples/component/src/Container.purs b/examples/component/src/Container.purs index 212fa03..1e6e877 100644 --- a/examples/component/src/Container.purs +++ b/examples/component/src/Container.purs @@ -1,16 +1,19 @@ module Container where -import React.Basic as React +import Prelude + +import React.Basic (Component, JSX, createComponent, makeStateless) import React.Basic.DOM as R -import ToggleButton as ToggleButton +import ToggleButton (toggleButton) + +component :: Component Unit +component = createComponent "Container" -component :: React.Component {} -component = React.stateless { displayName: "Container", render } - where - render _ = - R.div - { children: - [ React.element ToggleButton.component { label: "A" } - , React.element ToggleButton.component { label: "B" } - ] - } +toggleButtonContainer :: JSX +toggleButtonContainer = unit # makeStateless component \_ -> + R.div + { children: + [ toggleButton { label: "A" } + , toggleButton { label: "B" } + ] + } diff --git a/examples/component/src/Main.purs b/examples/component/src/Main.purs index d6cf566..67193a8 100644 --- a/examples/component/src/Main.purs +++ b/examples/component/src/Main.purs @@ -2,11 +2,10 @@ module Main where import Prelude -import Container as Container +import Container (toggleButtonContainer) import Data.Maybe (Maybe(..)) import Effect (Effect) import Effect.Exception (throw) -import React.Basic (element) import React.Basic.DOM (render) import Web.DOM.NonElementParentNode (getElementById) import Web.HTML (window) @@ -19,5 +18,5 @@ main = do case container of Nothing -> throw "Container element not found." Just c -> - let app = element Container.component {} + let app = toggleButtonContainer in render app c diff --git a/examples/component/src/ToggleButton.purs b/examples/component/src/ToggleButton.purs index 9b546bc..49430ae 100644 --- a/examples/component/src/ToggleButton.purs +++ b/examples/component/src/ToggleButton.purs @@ -3,33 +3,40 @@ module ToggleButton where import Prelude import Effect.Console (log) -import React.Basic as React +import React.Basic (Component, JSX, StateUpdate(..), capture_, createComponent, make) import React.Basic.DOM as R -import React.Basic.Events as Events + +component :: Component Props +component = createComponent "ToggleButton" type Props = { label :: String } -component :: React.Component Props -component = React.component { displayName: "ToggleButton", initialState, receiveProps, render } - where - initialState = +data Action + = Toggle + +toggleButton :: Props -> JSX +toggleButton = make component + { initialState: { on: false } - receiveProps _ = - pure unit + , update: \self -> case _ of + Toggle -> + UpdateAndSideEffects + self.state { on = not self.state.on } + \nextSelf -> do + log $ "next state: " <> show nextSelf.state - render { props, state, setStateThen } = + , render: \self -> R.button - { onClick: Events.handler_ do - setStateThen (\s -> s { on = not s.on }) \nextState -> do - log $ "nextState: " <> show nextState + { onClick: capture_ self Toggle , children: - [ R.text props.label - , R.text if state.on - then " On" - else " Off" + [ R.text self.props.label + , R.text if self.state.on + then " On" + else " Off" ] } + } diff --git a/examples/controlled-input/src/ControlledInput.purs b/examples/controlled-input/src/ControlledInput.purs index 509c746..7824483 100644 --- a/examples/controlled-input/src/ControlledInput.purs +++ b/examples/controlled-input/src/ControlledInput.purs @@ -3,34 +3,43 @@ module ControlledInput where import Prelude import Data.Maybe (Maybe(..), fromMaybe, maybe) +import React.Basic (Component, JSX, StateUpdate(..), capture, createComponent, make) import React.Basic as React import React.Basic.DOM as R -import React.Basic.DOM.Events (preventDefault, targetValue, timeStamp) -import React.Basic.Events as Events +import React.Basic.DOM.Events (targetValue, timeStamp) +import React.Basic.Events (merge) -component :: React.Component {} -component = React.component { displayName: "ControlledInput", initialState, receiveProps, render } - where - initialState = +component :: Component Props +component = createComponent "ControlledInput" + +type Props = Unit + +data Action + = ValueChanged String Number + +controlledInput :: Props -> JSX +controlledInput = make component + { initialState: { value: "hello world" - , timeStamp: Nothing + , timestamp: Nothing } - receiveProps _ = - pure unit + , update: \self -> case _ of + ValueChanged value timestamp -> + Update self.state + { value = value + , timestamp = Just timestamp + } - render { state, setState } = + , render: \self -> React.fragment [ R.input { onChange: - Events.handler - (preventDefault >>> Events.merge { targetValue, timeStamp }) - \{ timeStamp, targetValue } -> - setState _ { value = fromMaybe "" targetValue - , timeStamp = Just timeStamp - } - , value: state.value + capture self (merge { targetValue, timeStamp }) + \{ timeStamp, targetValue } -> ValueChanged (fromMaybe "" targetValue) timeStamp + , value: self.state.value } - , R.p_ [ R.text ("Current value = " <> show state.value) ] - , R.p_ [ R.text ("Changed at = " <> maybe "never" show state.timeStamp) ] + , R.p_ [ R.text ("Current value = " <> show self.state.value) ] + , R.p_ [ R.text ("Changed at = " <> maybe "never" show self.state.timestamp) ] ] + } diff --git a/examples/controlled-input/src/Main.purs b/examples/controlled-input/src/Main.purs index 69cc4b8..8651738 100644 --- a/examples/controlled-input/src/Main.purs +++ b/examples/controlled-input/src/Main.purs @@ -2,11 +2,10 @@ module Main where import Prelude -import ControlledInput as ControlledInput +import ControlledInput (controlledInput) import Data.Maybe (Maybe(..)) import Effect (Effect) import Effect.Exception (throw) -import React.Basic (element) import React.Basic.DOM (render) import Web.DOM.NonElementParentNode (getElementById) import Web.HTML (window) @@ -19,5 +18,5 @@ main = do case container of Nothing -> throw "Container element not found." Just c -> - let app = element ControlledInput.component {} + let app = controlledInput unit in render app c diff --git a/examples/counter/package.json b/examples/counter/package.json index 624a6f3..583c22b 100644 --- a/examples/counter/package.json +++ b/examples/counter/package.json @@ -1,9 +1,9 @@ { "dependencies": { - "react": "^16.4.2", - "react-dom": "^16.4.2" + "react": "16.6.0", + "react-dom": "16.6.0" }, "devDependencies": { - "browserify": "^16.2.2" + "browserify": "16.2.3" } } diff --git a/examples/counter/src/Counter.purs b/examples/counter/src/Counter.purs index cc49f94..cbea449 100644 --- a/examples/counter/src/Counter.purs +++ b/examples/counter/src/Counter.purs @@ -2,31 +2,31 @@ module Counter where import Prelude -import React.Basic as React + +import React.Basic (Component, JSX, StateUpdate(..), capture_, createComponent, make) import React.Basic.DOM as R -import React.Basic.Events as Events --- The props for the component +component :: Component Props +component = createComponent "Counter" + type Props = { label :: String } --- Create a component by passing a record to the `react` function. --- The `render` function takes the props and current state, as well as a --- state update callback, and produces a document. -component :: React.Component Props -component = React.component { displayName: "Counter", initialState, receiveProps, render } +data Action + = Increment + +counter :: Props -> JSX +counter = make component { initialState, update, render } where - initialState = - { counter: 0 - } + initialState = { counter: 0, dummy: 0 } - receiveProps _ = - pure unit + update self = case _ of + Increment -> + Update self.state { counter = self.state.counter + 1 } - render { props, state, setState } = + render self = R.button - { onClick: Events.handler_ do - setState \s -> s { counter = s.counter + 1 } - , children: [ R.text (props.label <> ": " <> show state.counter) ] + { onClick: capture_ self Increment + , children: [ R.text (self.props.label <> ": " <> show self.state.counter) ] } diff --git a/examples/counter/src/Main.purs b/examples/counter/src/Main.purs index 901a9d1..fa07fd4 100644 --- a/examples/counter/src/Main.purs +++ b/examples/counter/src/Main.purs @@ -2,11 +2,10 @@ module Main where import Prelude -import Counter as Counter +import Counter (counter) import Data.Maybe (Maybe(..)) import Effect (Effect) import Effect.Exception (throw) -import React.Basic (element) import React.Basic.DOM (render) import Web.DOM.NonElementParentNode (getElementById) import Web.HTML (window) @@ -19,5 +18,5 @@ main = do case container of Nothing -> throw "Container element not found." Just c -> - let app = element Counter.component { label: "Increment" } + let app = counter { label: "Increment" } in render app c diff --git a/examples/legacy-v2/.gitignore b/examples/legacy-v2/.gitignore new file mode 100644 index 0000000..645684d --- /dev/null +++ b/examples/legacy-v2/.gitignore @@ -0,0 +1,4 @@ +output +html/index.js +package-lock.json +node_modules diff --git a/examples/legacy-v2/Makefile b/examples/legacy-v2/Makefile new file mode 100644 index 0000000..ecacfbe --- /dev/null +++ b/examples/legacy-v2/Makefile @@ -0,0 +1,8 @@ +all: node_modules + purs compile src/*.purs '../../src/**/*.purs' '../../bower_components/purescript-*/src/**/*.purs' + purs bundle -m Main --main Main output/*/*.js > output/bundle.js + node_modules/.bin/browserify output/bundle.js -o html/index.js + +node_modules: + npm install + diff --git a/examples/legacy-v2/README.md b/examples/legacy-v2/README.md new file mode 100644 index 0000000..f2418c0 --- /dev/null +++ b/examples/legacy-v2/README.md @@ -0,0 +1,12 @@ +# Counter Example + +## Building + +``` +npm install +make all +``` + +This will compile the PureScript source files, bundle them, and use Browserify to combine PureScript and NPM sources into a single bundle. + +Then open `html/index.html` in your browser. diff --git a/examples/legacy-v2/html/index.html b/examples/legacy-v2/html/index.html new file mode 100644 index 0000000..6b93b7c --- /dev/null +++ b/examples/legacy-v2/html/index.html @@ -0,0 +1,10 @@ + + + + react-basic example + + +
+ + + diff --git a/examples/legacy-v2/package.json b/examples/legacy-v2/package.json new file mode 100644 index 0000000..624a6f3 --- /dev/null +++ b/examples/legacy-v2/package.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "react": "^16.4.2", + "react-dom": "^16.4.2" + }, + "devDependencies": { + "browserify": "^16.2.2" + } +} diff --git a/examples/legacy-v2/src/Compat.purs b/examples/legacy-v2/src/Compat.purs new file mode 100644 index 0000000..30a32cc --- /dev/null +++ b/examples/legacy-v2/src/Compat.purs @@ -0,0 +1,49 @@ +module React.Basic.Compat + ( Component + , component + , stateless + , module React.Basic + ) where + +import Prelude + +import Effect (Effect) +import React.Basic (JSX, ReactComponent, Self, StateUpdate(..), createComponent, element, elementKeyed, empty, fragment, make, makeStateless, send, toReactComponent) + +type Component = ReactComponent + +-- | Supports a common subset of the v2 API to ease the upgrade process +component + :: forall props state + . { displayName :: String + , initialState :: { | state } + , receiveProps :: { props :: { | props }, state :: { | state }, setState :: ({ | state } -> { | state }) -> Effect Unit } -> Effect Unit + , render :: { props :: { | props }, state :: { | state }, setState :: ({ | state } -> { | state }) -> Effect Unit } -> JSX + } + -> ReactComponent { | props } +component { displayName, initialState, receiveProps, render } = + toReactComponent identity (createComponent displayName) + { initialState: initialState + , didMount: receiveProps <<< selfToLegacySelf + , didUpdate: receiveProps <<< selfToLegacySelf + , update: \self stateUpdate -> Update (stateUpdate self.state) + , render: render <<< selfToLegacySelf + } + where + selfToLegacySelf self@{ props, state } = + { props + , state + , setState: send self + } + +-- | Supports a common subset of the v2 API to ease the upgrade process +stateless + :: forall props + . { displayName :: String + , render :: { | props } -> JSX + } + -> ReactComponent { | props } +stateless { displayName, render } = + toReactComponent identity (createComponent displayName) + { render: \self -> render self.props + } diff --git a/examples/legacy-v2/src/LegacyCounter.purs b/examples/legacy-v2/src/LegacyCounter.purs new file mode 100644 index 0000000..74d8e26 --- /dev/null +++ b/examples/legacy-v2/src/LegacyCounter.purs @@ -0,0 +1,36 @@ +module LegacyCounter where + +import Prelude + +import React.Basic.Compat (Component, component, element, stateless) +import React.Basic.DOM as R +import React.Basic.Events as Events + +type Props = + { label :: String + } + +-- | checks `component` +legacyCounter :: Component Props +legacyCounter = component { displayName: "LegacyCounter", initialState, receiveProps, render } + where + initialState = + { counter: 0 + } + + receiveProps self = + pure unit + + render self = + R.button + { onClick: Events.handler_ do + self.setState \s -> s { counter = s.counter + 1 } + , children: [ element buttonLabel { label: self.props.label, counter: self.state.counter } ] + } + +-- | checks `stateless` +buttonLabel :: Component { label :: String, counter :: Int } +buttonLabel = stateless { displayName: "ButtonLabel", render } + where + render props = + R.text (props.label <> ": " <> show props.counter) diff --git a/examples/legacy-v2/src/Main.purs b/examples/legacy-v2/src/Main.purs new file mode 100644 index 0000000..bdb51c9 --- /dev/null +++ b/examples/legacy-v2/src/Main.purs @@ -0,0 +1,23 @@ +module Main where + +import Prelude + +import Data.Maybe (Maybe(..)) +import Effect (Effect) +import Effect.Exception (throw) +import LegacyCounter (legacyCounter) +import React.Basic (element) +import React.Basic.DOM (render) +import Web.DOM.NonElementParentNode (getElementById) +import Web.HTML (window) +import Web.HTML.HTMLDocument (toNonElementParentNode) +import Web.HTML.Window (document) + +main :: Effect Unit +main = do + container <- getElementById "container" =<< (map toNonElementParentNode $ document =<< window) + case container of + Nothing -> throw "Container element not found." + Just c -> + let app = element legacyCounter { label: "Increment" } + in render app c diff --git a/generated-docs/React/Basic.md b/generated-docs/React/Basic.md index abab069..2832ee1 100644 --- a/generated-docs/React/Basic.md +++ b/generated-docs/React/Basic.md @@ -1,80 +1,342 @@ ## Module React.Basic +#### `ComponentSpec` + +``` purescript +type ComponentSpec props state initialState action = { initialState :: initialState, update :: Self props state action -> action -> StateUpdate props state action, render :: Self props state action -> JSX, shouldUpdate :: Self props state action -> props -> state -> Boolean, didMount :: Self props state action -> Effect Unit, didUpdate :: Self props state action -> Effect Unit, willUnmount :: Self props state action -> Effect Unit, "$$type" :: ComponentType props state action } +``` + +`ComponentSpec` represents a React-Basic component implementation. + +These are the properties your component definition may override +with specific implementations. None are required to be overridden, unless +an overridden function interacts with `state`, in which case `initialState` +is required (the compiler enforces this). While you _can_ use `state` and +dispatch actions without defining `update`, doing so doesn't make much sense +so the default `update` implementation will emit a warning. + +- `initialState` + - The component's starting state. + - Avoid mirroring prop values in state. +- `update` + - All state updates go through `update`. + - `update` is called when `send` is used to dispatch an action. + - State changes are described using `StateUpdate`. Only `Update` and `UpdateAndSideEffects` will cause rerenders and a call to `didUpdate`. + - Side effects requested are only invoked _after_ any corrosponding state update has completed its render cycle and the changes have been applied. This means it is safe to interact with the DOM in a side effect, for example. +- `render` + - Takes a current snapshot of the component (`Self`) and converts it to renderable `JSX`. +- `shouldUpdate` + - Can be useful for occasional performance optimizations. Rarely necessary. +- `didMount` + - The React component's `componentDidMount` lifecycle. Useful for initiating an action on first mount, such as fetching data from a server. +- `didUpdate` + - The React component's `componentDidUpdate` lifecycle. Rarely necessary. +- `willUnmount` + - The React component's `componentWillUpdate` lifecycle. Any subscriptions or timers created in `didMount` or `didUpdate` should be disposed of here. + +The component spec is generally not exported from your component +module and this type is rarely used explicitly. The simplified alias +`Component` is usually sufficient, and `make` will validate whether +your component's types line up. + +For example: + +```purs +component :: Component +component = createComponent "Counter" + +type Props = + { label :: String + } + +data Action + = Increment + +counter :: Props -> JSX +counter = make component + { initialState = { counter: 0 } + + , update = \self action -> case action of + Increment -> + Update self.state { counter = self.state.counter + 1 } + + , render = \self -> + R.button + { onClick: capture_ self Increment + , children: [ R.text (self.props.label <> ": " <> show self.state.counter) ] + } + } +``` + +This example component overrides `initialState`, `update`, and `render`. + +__*Note:* A `ComponentSpec` is *not* a valid React component by itself. If you would like to use + a React-Basic component from JavaScript, use `toReactComponent`.__ + +__*Note:* `$$type` is for internal use only. It needs to be on the type to + preserve its existence during a record update, as in the example above.__ + +__*See also:* `Component`, `ComponentSpec`, `make`, `makeStateless`__ + +#### `createComponent` + +``` purescript +createComponent :: forall props state action. String -> ComponentSpec props state Unit action +``` + +Creates a `ComponentSpec` with a given Display Name. + +The resulting component spec is usually given the simplified `Component` type: + +```purs +component :: Component +component = createComponent "Counter" +``` + +This function should be used at the module level and considered side effecting. +This is because React uses referential equality when deciding whether a new +`JSX` tree is a valid update, or if it needs to be replaced entirely +(expensive and clears component state lower in the tree). + +__*Note:* A `Component` is *not* a valid React component by itself. If you would like to use + a React-Basic component from JavaScript, use `toReactComponent`.__ + +__*See also:* `Component`, `ComponentSpec`, `make`, `makeStateless`__ + #### `Component` ``` purescript -data Component :: Type -> Type +type Component = forall props state action. ComponentSpec props state Unit action ``` -A React component which can be used from JavaScript. +A simplified alias for `ComponentSpec`. This type is usually used to represent +the default component type returned from `createComponent`. -#### `ComponentInstance` +#### `ComponentType` ``` purescript -data ComponentInstance :: Type +data ComponentType props state action ``` -Represents the mounted component instance, or "this" in vanilla React. +Opaque component information for internal use. -#### `JSX` +__*For the curious:* This is the "class" React will use to render and + identify the component. It receives the `ComponentSpec` as a prop and knows + how to defer behavior to it. It requires very specific props and is not useful by + itself from JavaScript. For JavaScript interop, see `toReactComponent`.__ + +#### `StateUpdate` ``` purescript -data JSX :: Type +data StateUpdate props state action + = NoUpdate + | Update state + | SideEffects (Self props state action -> Effect Unit) + | UpdateAndSideEffects state (Self props state action -> Effect Unit) ``` -A virtual DOM element. +Used by the `update` function to describe the kind of state update and/or side +effects desired. + +__*See also:* `ComponentSpec`__ + +#### `Self` -##### Instances ``` purescript -Semigroup JSX -Monoid JSX +type Self props state action = { props :: props, state :: state, instance_ :: ReactComponentInstance } +``` + +`Self` represents the component instance at a particular point in time. + +- `props` + - A snapshot of `props` taken when this `Self` was created. +- `state` + - A snapshot of `state` taken when this `Self` was created. +- `instance_` + - Unsafe escape hatch to the underlying component instance (`this` in the JavaScript React paradigm). Avoid as much as possible, but it's still frequently better than rewriting an entire component in JavaScript. + +__*See also:* `ComponentSpec`, `send`, `capture`, `readProps`, `readState`__ + +#### `send` + +``` purescript +send :: forall props state action. Self props state action -> action -> Effect Unit ``` -#### `component` +Dispatch an `action` into the component to be handled by `update`. + +__*See also:* `update`, `capture`__ + +#### `sendAsync` + +``` purescript +sendAsync :: forall props state action. Self props state action -> Aff action -> Effect Unit +``` + +Convenience function for sending an action when an `Aff` completes. + +__*Note:* Potential failure should be handled in the given `Aff` and converted + to an action, as the default error handler will simply log the error to + the console.__ + +__*See also:* `send`__ + +#### `capture` ``` purescript -component :: forall props state. { displayName :: String, initialState :: { | state }, receiveProps :: { isFirstMount :: Boolean, props :: { | props }, state :: { | state }, setState :: SetState state, setStateThen :: SetStateThen state, instance_ :: ComponentInstance } -> Effect Unit, render :: { props :: { | props }, state :: { | state }, setState :: SetState state, setStateThen :: SetStateThen state, instance_ :: ComponentInstance } -> JSX } -> Component { | props } +capture :: forall props state action a. Self props state action -> EventFn SyntheticEvent a -> (a -> action) -> EventHandler ``` -Create a React component from a _specification_ of that component. +Create a capturing\* `EventHandler` to send an action when an event occurs. For +more complicated event handlers requiring `Effect`, use `handler` from `React.Basic.Events`. + +__\*calls `preventDefault` and `stopPropagation`__ + +__*See also:* `update`, `capture_`, `monitor`, `React.Basic.Events`__ -A _specification_ consists of a state type, an initial value for that state, -a function to apply incoming props to the internal state, and a rendering -function which takes props, state and a state update function. +#### `capture_` -The rendering function should return a value of type `JSX`, which can be -constructed using the helper functions provided by the `React.Basic.DOM` -module. +``` purescript +capture_ :: forall props state action. Self props state action -> action -> EventHandler +``` -Note: This function relies on `React.PureComponent` internally +Like `capture`, but for actions which don't need to extract information from the Event. -#### `stateless` +__*See also:* `update`, `capture`, `monitor_`__ + +#### `monitor` ``` purescript -stateless :: forall props. { displayName :: String, render :: { | props } -> JSX } -> Component { | props } +monitor :: forall props state action a. Self props state action -> EventFn SyntheticEvent a -> (a -> action) -> EventHandler ``` -Create a stateless React component. +Like `capture`, but does not cancel the event. -Removes a little bit of the `react` function's boilerplate when creating -components which don't use state. +__*See also:* `update`, `capture`, `monitor\_`__ -#### `element` +#### `monitor_` ``` purescript -element :: forall props. Component { | props } -> { | props } -> JSX +monitor_ :: forall props state action. Self props state action -> action -> EventHandler ``` -Create a `JSX` node from a React component, by providing the props. +Like `capture_`, but does not cancel the event. -#### `elementKeyed` +__*See also:* `update`, `monitor`, `capture_`, `React.Basic.Events`__ + +#### `readProps` + +``` purescript +readProps :: forall props state action. Self props state action -> Effect props +``` + +Read the most up to date `props` directly from the component instance +associated with this `Self`. + +_Note: This function is for specific, asynchronous edge cases. + Generally, the `props` snapshot on `Self` is sufficient. + +__*See also:* `Self`__ + +#### `readState` + +``` purescript +readState :: forall props state action. Self props state action -> Effect state +``` + +Read the most up to date `state` directly from the component instance +associated with this `Self`. + +_Note: This function is for specific, asynchronous edge cases. + Generally, the `state` snapshot on `Self` is sufficient. + +__*See also:* `Self`__ + +#### `make` + +``` purescript +make :: forall props state action. ComponentSpec props state state action -> props -> JSX +``` + +Turn a `Component` into a usable render function. +This is where you will want to provide customized implementations: + +```purs +component :: Component +component = createComponent "Counter" + +type Props = + { label :: String + } + +data Action + = Increment + +counter :: Props -> JSX +counter = make component + { initialState = { counter: 0 } + + , update = \self action -> case action of + Increment -> + Update self.state { counter = self.state.counter + 1 } + + , render = \self -> + R.button + { onClick: capture_ self Increment + , children: [ R.text (self.props.label <> ": " <> show self.state.counter) ] + } + } +``` + +__*See also:* `makeStateless`, `createComponent`, `Component`, `ComponentSpec`__ + +#### `makeStateless` ``` purescript -elementKeyed :: forall props. Component { | props } -> { key :: String | props } -> JSX +makeStateless :: forall props. ComponentSpec props Unit Unit Unit -> (props -> JSX) -> props -> JSX +``` + +Makes stateless component definition slightly less verbose: + +```purs +component :: Component +component = createComponent "Xyz" + +myComponent :: Props -> JSX +myComponent = makeStateless component \props -> JSX ``` -Like `element`, plus a `key` for rendering components in a dynamic list. -For more information see: https://reactjs.org/docs/reconciliation.html#keys +__*Note:* The only difference between a stateless React-Basic component and + a plain `props -> JSX` function is the presense of the component name + in React's dev tools and error stacks. It's just a conceptual boundary. + If this isn't important simply write a `props -> JSX` function.__ + +__*See also:* `make`, `createComponent`, `Component`, `ComponentSpec`__ + +#### `JSX` + +``` purescript +data JSX :: Type +``` + +Represents rendered React VDOM (the result of calling `React.createElement` +in JavaScript). + +`JSX` is a `Monoid`: + +- `append` + - Merge two `JSX` nodes using `React.Fragment`. +- `mempty` + - The `empty` node; renders nothing. + +__*Hint:* Many useful utility functions already exist for Monoids. For example, + `guard` can be used to conditionally render a subtree of components.__ + +##### Instances +``` purescript +Semigroup JSX +Monoid JSX +``` #### `empty` @@ -82,10 +344,24 @@ For more information see: https://reactjs.org/docs/reconciliation.html#keys empty :: JSX ``` -An empty node. This is often useful when you would like to conditionally +An empty `JSX` node. This is often useful when you would like to conditionally show something, but you don't want to (or can't) modify the `children` prop on the parent node. +__*See also:* `JSX`, Monoid `guard`__ + +#### `keyed` + +``` purescript +keyed :: String -> JSX -> JSX +``` + +Apply a React key to a subtree. React-Basic usually hides React's warning about +using `key` props on components in an Array, but keys are still important for +any dynamic lists of child components. + +__*See also:* React's documentation regarding the special `key` prop__ + #### `fragment` ``` purescript @@ -94,15 +370,73 @@ fragment :: Array JSX -> JSX Render an Array of children without a wrapping component. -#### `fragmentKeyed` +__*See also:* `JSX`__ + +#### `element` ``` purescript -fragmentKeyed :: String -> Array JSX -> JSX +element :: forall props. ReactComponent { | props } -> { | props } -> JSX ``` -Render an Array of children without a wrapping component. +Create a `JSX` node from a `ReactComponent`, by providing the props. + +This function is for non-React-Basic React components, such as those +imported from FFI. + +__*See also:* `ReactComponent`, `elementKeyed`__ + +#### `elementKeyed` + +``` purescript +elementKeyed :: forall props. ReactComponent { | props } -> { key :: String | props } -> JSX +``` + +Create a `JSX` node from a `ReactComponent`, by providing the props and a key. + +This function is for non-React-Basic React components, such as those +imported from FFI. + +__*See also:* `ReactComponent`, `element`, React's documentation regarding the special `key` prop__ + +#### `ReactComponent` + +``` purescript +data ReactComponent props +``` + +Represents a traditional React component. Useful for JavaScript interop and +FFI. For example: + +```purs +foreign import ComponentRequiringJSHacks :: ReactComponent { someProp :: String } +``` + +__*See also:* `element`, `toReactComponent`__ + +#### `ReactComponentInstance` + +``` purescript +data ReactComponentInstance +``` + +An opaque representation of a React component's instance (`this` in the JavaScript +React paradigm). It exists as an escape hatch to unsafe behavior. Use it with +caution. + +#### `toReactComponent` + +``` purescript +toReactComponent :: forall jsProps props state action. ({ | jsProps } -> props) -> ComponentSpec props state state action -> ReactComponent { | jsProps } +``` + +Convert a React-Basic `ComponentSpec` to a JavaScript-friendly React component. +This function should only be used for JS interop and not normal React-Basic usage. + +__*Note:* Like `createComponent`, `toReactComponent` is side effecting in that + it creates a "class" React will see as unique each time it's called. Lift + any usage up to the module level, usage in `render` or any other function, + and applying any type classes to the `props`.__ -Provide a key when dynamically rendering multiple fragments along side -each other. +__*See also:* `ReactComponent`__ diff --git a/generated-docs/React/Basic/DOM.md b/generated-docs/React/Basic/DOM.md index b36435f..7afcb7b 100644 --- a/generated-docs/React/Basic/DOM.md +++ b/generated-docs/React/Basic/DOM.md @@ -74,7 +74,7 @@ Note: Relies on `ReactDOM.unmountComponentAtNode` #### `findDOMNode` ``` purescript -findDOMNode :: ComponentInstance -> Effect (Either Error Node) +findDOMNode :: ReactComponentInstance -> Effect (Either Error Node) ``` Returns the current DOM node associated with the given @@ -2194,6 +2194,6 @@ An abstract type representing records of CSS attributes. #### `unsafeCreateDOMComponent` ``` purescript -unsafeCreateDOMComponent :: forall props. String -> Component props +unsafeCreateDOMComponent :: forall props. String -> ReactComponent props ``` diff --git a/generated-docs/React/Basic/DOM/Internal.md b/generated-docs/React/Basic/DOM/Internal.md index b07c72d..8594101 100644 --- a/generated-docs/React/Basic/DOM/Internal.md +++ b/generated-docs/React/Basic/DOM/Internal.md @@ -19,7 +19,7 @@ Standard props which are shared by all DOM elements. #### `unsafeCreateDOMComponent` ``` purescript -unsafeCreateDOMComponent :: forall props. String -> Component props +unsafeCreateDOMComponent :: forall props. String -> ReactComponent props ``` diff --git a/package-lock.json b/package-lock.json index b969c5f..a609272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1498,14 +1498,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1520,20 +1518,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -1650,8 +1645,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -1663,7 +1657,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -1678,7 +1671,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1686,14 +1678,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -1712,7 +1702,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -1793,8 +1782,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -1806,7 +1794,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -1928,7 +1915,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2794,9 +2780,9 @@ "dev": true }, "nan": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", - "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.1.tgz", + "integrity": "sha512-iji6k87OSXa0CcrLl9z+ZiYSuR2o+c0bGuNmXdrhTQTakxytAFsC56SArGYoiHlJlFoHSnvmhpceZJaXkVuOtA==", "dev": true, "optional": true }, diff --git a/src/React/Basic.js b/src/React/Basic.js index a2181c1..2ffd725 100644 --- a/src/React/Basic.js +++ b/src/React/Basic.js @@ -3,64 +3,161 @@ var React = require("react"); var Fragment = React.Fragment || "div"; -function setStateThen(instance) { - return function(update, then) { - return instance.setState(update, function() { - then(this.state); - }); - }; -} +exports.createComponent = (function() { + // Begin component prototype functions + // (`this`-dependent, defined outside `createComponent` + // for a slight performance boost) + function toSelf() { + var self = { + props: this.props.$$props, + state: this.state === null ? null : this.state.$$state, + instance_: this + }; + return self; + } -exports.component_ = function(spec) { - var Component = function constructor() { - this.state = spec.initialState; - this._setState = this.setState.bind(this); - return this; - }; + function shouldComponentUpdate(nextProps, nextState) { + var shouldUpdate = this.$$spec.shouldUpdate; + return shouldUpdate === undefined + ? true + : shouldUpdate(this.toSelf())(nextProps.$$props)( + nextState === null ? null : nextState.$$state + ); + } - Component.prototype = Object.create(React.PureComponent.prototype); + function componentDidMount() { + var didMount = this.$$spec.didMount; + if (didMount !== undefined) { + didMount(this.toSelf())(); + } + } - Component.displayName = spec.displayName; + function componentDidUpdate() { + var didUpdate = this.$$spec.didUpdate; + if (didUpdate !== undefined) { + didUpdate(this.toSelf())(); + } + } - Component.prototype.componentDidMount = function componentDidMount() { - spec.receiveProps({ - isFirstMount: true, - props: this.props, - state: this.state, - setState: this._setState, - setStateThen: setStateThen(this), - instance_: this - }); + function componentWillUnmount() { + this.$$mounted = false; + var willUnmount = this.$$spec.willUnmount; + if (willUnmount !== undefined) { + willUnmount(this.toSelf())(); + } + } + + function render() { + return this.$$spec.render(this.toSelf()); + } + // End component prototype functions + + return function(displayName) { + var Component = function constructor(props) { + this.$$mounted = true; + this.$$spec = props.$$spec; + this.state = + // React may optimize components with no state, + // so we leave state null if it was left as + // the default value. + this.$$spec.initialState === undefined + ? null + : { $$state: this.$$spec.initialState }; + return this; + }; + + Component.displayName = displayName; + Component.prototype = Object.create(React.Component.prototype); + Component.prototype.constructor = Component; + Component.prototype.toSelf = toSelf; + Component.prototype.shouldComponentUpdate = shouldComponentUpdate; + Component.prototype.componentDidMount = componentDidMount; + Component.prototype.componentDidUpdate = componentDidUpdate; + Component.prototype.componentWillUnmount = componentWillUnmount; + Component.prototype.render = render; + + return Component; }; +})(); - Component.prototype.componentDidUpdate = function componentDidUpdate() { - spec.receiveProps({ - isFirstMount: false, - props: this.props, - state: this.state, - setState: this._setState, - setStateThen: setStateThen(this), - instance_: this - }); +exports.send_ = function(buildStateUpdate) { + return function(self, action) { + if (!self.instance_.$$mounted) { + exports.warningUnmountedComponentAction(self, action); + return; + } + if (self.instance_.$$spec.update === undefined) { + exports.warningDefaultUpdate(self, action); + return; + } + var sideEffects = null; + self.instance_.setState( + function(s) { + var setStateContext = self.instance_.toSelf(); + setStateContext.state = s.$$state; + var updates = buildStateUpdate( + self.instance_.$$spec.update(setStateContext)(action) + ); + if (updates.effects !== null) { + sideEffects = updates.effects; + } + if (updates.state !== null && updates.state !== s.$$state) { + return { $$state: updates.state }; + } else { + return null; + } + }, + function() { + if (sideEffects !== null) { + sideEffects(this.toSelf())(); + } + } + ); }; +}; - Component.prototype.render = function render() { - return spec.render({ - props: this.props, - state: this.state, - setState: this._setState, - setStateThen: setStateThen(this), - instance_: this - }); +exports.readProps = function(self) { + return self.instance_.props.$$props; +}; + +exports.readState = function(self) { + var state = self.instance_.state; + return state === null ? null : state.$$state; +}; + +exports.make = function(_unionDict) { + return function($$type) { + return function($$spec) { + var $$specPadded = { + initialState: $$spec.initialState, + update: $$spec.update, + render: $$spec.render, + shouldUpdate: $$spec.shouldUpdate, + didMount: $$spec.didMount, + didUpdate: $$spec.didUpdate, + willUnmount: $$spec.willUnmount + }; + return function($$props) { + var props = { + $$props: $$props, + $$spec: $$specPadded + }; + return React.createElement($$type, props); + }; + }; }; +}; + +exports.empty = null; - return Component; +exports.keyed_ = function(key, child) { + return React.createElement(Fragment, { key: key }, child); }; -exports.element_ = function(el, attrs) { +exports.element_ = function(component, props) { return React.createElement.apply( null, - [el, attrs].concat((attrs && attrs.children) || []) + [component, props].concat((props && props.children) || null) ); }; @@ -70,9 +167,75 @@ exports.fragment = function(children) { return React.createElement.apply(null, [Fragment, {}].concat(children)); }; -exports.fragmentKeyed_ = function(key, children) { - return React.createElement.apply( - null, - [Fragment, { key: key }].concat(children) +exports.displayNameFromComponent = function($$type) { + return $$type.displayName || "[unknown]"; +}; + +exports.displayNameFromSelf = function(self) { + return exports.displayNameFromComponent(self.instance_.constructor); +}; + +exports.toReactComponent = function(_unionDict) { + return function(fromJSProps) { + return function($$type) { + return function($$spec) { + var $$specPadded = { + initialState: $$spec.initialState, + update: $$spec.update, + render: $$spec.render, + shouldUpdate: $$spec.shouldUpdate, + didMount: $$spec.didMount, + didUpdate: $$spec.didUpdate, + willUnmount: $$spec.willUnmount + }; + + var Component = function constructor() { + return this; + }; + + Component.prototype = Object.create(React.Component.prototype); + + Component.displayName = $$type.displayName + " (Wrapper)"; + + Component.prototype.render = function() { + var props = { + $$props: fromJSProps(this.props), + $$spec: $$specPadded + }; + return React.createElement($$type, props); + }; + + return Component; + }; + }; + }; +}; + +exports.warningDefaultUpdate = function(self, action) { + console.error( + "A " + + exports.displayNameFromSelf(self) + + " component received an action but has no `update` function defined. Override the default `update` function to handle this action." + ); + console.error("Self:", self); + console.error("Action:", action); +}; + +exports.warningUnmountedComponentAction = function(self, action) { + console.error( + "An unmounted " + + exports.displayNameFromSelf(self) + + " component received the action below. Actions received by unmounted components usually indicate a memory leak. Make sure to unsubscribe from any async work in `willUnmount`." + ); + console.error("Self:", self); + console.error("Action:", action); +}; + +exports.warningFailedAsyncAction = function(self, error) { + console.error( + "An async action failed in a " + + exports.displayNameFromSelf(self) + + " component." ); + console.error(error); }; diff --git a/src/React/Basic.purs b/src/React/Basic.purs index 2438ccd..b6bae67 100644 --- a/src/React/Basic.purs +++ b/src/React/Basic.purs @@ -1,24 +1,334 @@ module React.Basic - ( Component - , ComponentInstance + ( ComponentSpec + , createComponent + , Component + , StateUpdate(..) + , Self + , send + , sendAsync + , capture + , capture_ + , monitor + , monitor_ + , readProps + , readState + , make + , makeStateless , JSX - , component - , stateless - , element - , elementKeyed , empty + , keyed , fragment - , fragmentKeyed + , element + , elementKeyed + , displayNameFromComponent + , displayNameFromSelf + , ReactComponent + , ReactComponentInstance + , toReactComponent ) where import Prelude -import Data.Function.Uncurried (Fn1, Fn2, mkFn1, runFn2) +import Data.Either (Either(..)) +import Data.Function.Uncurried (Fn1, Fn2, runFn1, runFn2) +import Data.Nullable (Nullable, notNull, null) import Effect (Effect) -import Effect.Uncurried (EffectFn1, EffectFn2, mkEffectFn1, runEffectFn1, runEffectFn2) -import Unsafe.Coerce (unsafeCoerce) +import Effect.Aff (Aff, Error, runAff_) +import Effect.Uncurried (EffectFn2, runEffectFn2) +import React.Basic.DOM.Events (preventDefault, stopPropagation) +import React.Basic.Events (EventFn, EventHandler, SyntheticEvent, handler) +import Type.Row (class Union) + +-- | `ComponentSpec` represents a React-Basic component implementation. +-- | +-- | These are the properties your component definition may override +-- | with specific implementations. None are required to be overridden, unless +-- | an overridden function interacts with `state`, in which case `initialState` +-- | is required (the compiler enforces this). While you _can_ use `state` and +-- | dispatch actions without defining `update`, doing so doesn't make much sense +-- | so the default `update` implementation will emit a warning. +-- | +-- | - `initialState` +-- | - The component's starting state. +-- | - Avoid mirroring prop values in state. +-- | - `update` +-- | - All state updates go through `update`. +-- | - `update` is called when `send` is used to dispatch an action. +-- | - State changes are described using `StateUpdate`. Only `Update` and `UpdateAndSideEffects` will cause rerenders and a call to `didUpdate`. +-- | - Side effects requested are only invoked _after_ any corrosponding state update has completed its render cycle and the changes have been applied. This means it is safe to interact with the DOM in a side effect, for example. +-- | - `render` +-- | - Takes a current snapshot of the component (`Self`) and converts it to renderable `JSX`. +-- | - `shouldUpdate` +-- | - Can be useful for occasional performance optimizations. Rarely necessary. +-- | - `didMount` +-- | - The React component's `componentDidMount` lifecycle. Useful for initiating an action on first mount, such as fetching data from a server. +-- | - `didUpdate` +-- | - The React component's `componentDidUpdate` lifecycle. Rarely necessary. +-- | - `willUnmount` +-- | - The React component's `componentWillUpdate` lifecycle. Any subscriptions or timers created in `didMount` or `didUpdate` should be disposed of here. +-- | +-- | The component spec is generally not exported from your component +-- | module and this type is rarely used explicitly. The simplified alias +-- | `Component` is usually sufficient, and `make` will validate whether +-- | your component's types line up. +-- | +-- | For example: +-- | +-- | ```purs +-- | component :: Component Props +-- | component = createComponent "Counter" +-- | +-- | type Props = +-- | { label :: String +-- | } +-- | +-- | data Action +-- | = Increment +-- | +-- | counter :: Props -> JSX +-- | counter = make component +-- | { initialState = { counter: 0 } +-- | +-- | , update = \self action -> case action of +-- | Increment -> +-- | Update self.state { counter = self.state.counter + 1 } +-- | +-- | , render = \self -> +-- | R.button +-- | { onClick: capture_ self Increment +-- | , children: [ R.text (self.props.label <> ": " <> show self.state.counter) ] +-- | } +-- | } +-- | ``` +-- | +-- | This example component overrides `initialState`, `update`, and `render`. +-- | +-- | __*Note:* A `ComponentSpec` is *not* a valid React component by itself. If you would like to use +-- | a React-Basic component from JavaScript, use `toReactComponent`.__ +-- | +-- | __*See also:* `Component`, `ComponentSpec`, `make`, `makeStateless`__ +type ComponentSpec props state action = + ( initialState :: state + , update :: Self props state action -> action -> StateUpdate props state action + , render :: Self props state action -> JSX + , shouldUpdate :: Self props state action -> props -> state -> Boolean + , didMount :: Self props state action -> Effect Unit + , didUpdate :: Self props state action -> Effect Unit + , willUnmount :: Self props state action -> Effect Unit + ) + +-- | Creates a `ComponentSpec` with a given Display Name. +-- | +-- | The resulting component spec is usually given the simplified `Component` type: +-- | +-- | ```purs +-- | component :: Component Props +-- | component = createComponent "Counter" +-- | ``` +-- | +-- | This function should be used at the module level and considered side effecting. +-- | This is because React uses referential equality when deciding whether a new +-- | `JSX` tree is a valid update, or if it needs to be replaced entirely +-- | (expensive and clears component state lower in the tree). +-- | +-- | __*Note:* A specific type for the props in `Component props` should always be chosen at this point. +-- | It's technically possible to declare the component with `forall props. Component props` +-- | but doing so is unsafe. Leaving the prop type open allows the use of a single `Component` +-- | definition in multiple React-Basic components that may have different prop types. Because +-- | component lifecycles are managed by React, it's possible for incompatible prop values to +-- | be passed into a lifecycle function.__ +-- | +-- | __*Note:* A `Component` is *not* a valid React component by itself. If you would like to use +-- | a React-Basic component from JavaScript, use `toReactComponent`.__ +-- | +-- | __*See also:* `Component`, `ComponentSpec`, `make`, `makeStateless`__ +foreign import createComponent + :: forall props + . String + -> Component props + +-- | A simplified alias for `ComponentSpec`. This type is usually used to represent +-- | the default component type returned from `createComponent`. +-- type Component props = forall state action. ComponentSpec props state action + +-- | Opaque component information for internal use. +-- | +-- | __*For the curious:* This is the "class" React will use to render and +-- | identify the component. It receives the `ComponentSpec` as a prop and knows +-- | how to defer behavior to it. It requires very specific props and is not useful by +-- | itself from JavaScript. For JavaScript interop, see `toReactComponent`.__ +data Component props + +-- | Used by the `update` function to describe the kind of state update and/or side +-- | effects desired. +-- | +-- | __*See also:* `ComponentSpec`__ +data StateUpdate props state action + = NoUpdate + | Update state + | SideEffects (Self props state action -> Effect Unit) + | UpdateAndSideEffects state (Self props state action -> Effect Unit) + +-- | `Self` represents the component instance at a particular point in time. +-- | +-- | - `props` +-- | - A snapshot of `props` taken when this `Self` was created. +-- | - `state` +-- | - A snapshot of `state` taken when this `Self` was created. +-- | - `instance_` +-- | - Unsafe escape hatch to the underlying component instance (`this` in the JavaScript React paradigm). Avoid as much as possible, but it's still frequently better than rewriting an entire component in JavaScript. +-- | +-- | __*See also:* `ComponentSpec`, `send`, `capture`, `readProps`, `readState`__ +type Self props state action = + { props :: props + , state :: state + , instance_ :: ReactComponentInstance + } + +-- | Dispatch an `action` into the component to be handled by `update`. +-- | +-- | __*See also:* `update`, `capture`__ +send :: forall props state action. Self props state action -> action -> Effect Unit +send = runEffectFn2 (runFn1 send_ buildStateUpdate) + +-- | Convenience function for sending an action when an `Aff` completes. +-- | +-- | __*Note:* Potential failure should be handled in the given `Aff` and converted +-- | to an action, as the default error handler will simply log the error to +-- | the console.__ +-- | +-- | __*See also:* `send`__ +sendAsync + :: forall props state action + . Self props state action + -> Aff action + -> Effect Unit +sendAsync self work = runAff_ handle work + where + handle (Right action) = send self action + handle (Left err) = runEffectFn2 warningFailedAsyncAction self err + +-- | Create a capturing\* `EventHandler` to send an action when an event occurs. For +-- | more complicated event handlers requiring `Effect`, use `handler` from `React.Basic.Events`. +-- | +-- | __\*calls `preventDefault` and `stopPropagation`__ +-- | +-- | __*See also:* `update`, `capture_`, `monitor`, `React.Basic.Events`__ +capture :: forall props state action a. Self props state action -> EventFn SyntheticEvent a -> (a -> action) -> EventHandler +capture self eventFn = monitor self (preventDefault >>> stopPropagation >>> eventFn) + +-- | Like `capture`, but for actions which don't need to extract information from the Event. +-- | +-- | __*See also:* `update`, `capture`, `monitor_`__ +capture_ :: forall props state action. Self props state action -> action -> EventHandler +capture_ self action = capture self identity \_ -> action + +-- | Like `capture`, but does not cancel the event. +-- | +-- | __*See also:* `update`, `capture`, `monitor\_`__ +monitor :: forall props state action a. Self props state action -> EventFn SyntheticEvent a -> (a -> action) -> EventHandler +monitor self eventFn makeAction = handler eventFn \a -> send self (makeAction a) + +-- | Like `capture_`, but does not cancel the event. +-- | +-- | __*See also:* `update`, `monitor`, `capture_`, `React.Basic.Events`__ +monitor_ :: forall props state action. Self props state action -> action -> EventHandler +monitor_ self action = monitor self identity \_ -> action + +-- | Read the most up to date `props` directly from the component instance +-- | associated with this `Self`. +-- | +-- | _Note: This function is for specific, asynchronous edge cases. +-- | Generally, the `props` snapshot on `Self` is sufficient. +-- | +-- | __*See also:* `Self`__ +foreign import readProps :: forall props state action. Self props state action -> Effect props + +-- | Read the most up to date `state` directly from the component instance +-- | associated with this `Self`. +-- | +-- | _Note: This function is for specific, asynchronous edge cases. +-- | Generally, the `state` snapshot on `Self` is sufficient. +-- | +-- | __*See also:* `Self`__ +foreign import readState :: forall props state action. Self props state action -> Effect state + +-- | Turn a `Component` into a usable render function. +-- | This is where you will want to provide customized implementations: +-- | +-- | ```purs +-- | component :: Component Props +-- | component = createComponent "Counter" +-- | +-- | type Props = +-- | { label :: String +-- | } +-- | +-- | data Action +-- | = Increment +-- | +-- | counter :: Props -> JSX +-- | counter = make component +-- | { initialState = { counter: 0 } +-- | +-- | , update = \self action -> case action of +-- | Increment -> +-- | Update self.state { counter = self.state.counter + 1 } +-- | +-- | , render = \self -> +-- | R.button +-- | { onClick: capture_ self Increment +-- | , children: [ R.text (self.props.label <> ": " <> show self.state.counter) ] +-- | } +-- | } +-- | ``` +-- | +-- | __*See also:* `makeStateless`, `createComponent`, `Component`, `ComponentSpec`__ +foreign import make + :: forall spec spec_ props state action + . Union spec spec_ (ComponentSpec props state action) + => Component props + -> { render :: Self props state action -> JSX | spec } + -> props + -> JSX --- | A virtual DOM element. +-- | Makes stateless component definition slightly less verbose: +-- | +-- | ```purs +-- | component :: Component Props +-- | component = createComponent "Xyz" +-- | +-- | myComponent :: Props -> JSX +-- | myComponent = makeStateless component \props -> JSX +-- | ``` +-- | +-- | __*Note:* The only difference between a stateless React-Basic component and +-- | a plain `props -> JSX` function is the presense of the component name +-- | in React's dev tools and error stacks. It's just a conceptual boundary. +-- | If this isn't important simply write a `props -> JSX` function.__ +-- | +-- | __*See also:* `make`, `createComponent`, `Component`, `ComponentSpec`__ +makeStateless + :: forall props + . Component props + -> (props -> JSX) + -> props + -> JSX +makeStateless component render = + make component { render: \self -> render self.props } + +-- | Represents rendered React VDOM (the result of calling `React.createElement` +-- | in JavaScript). +-- | +-- | `JSX` is a `Monoid`: +-- | +-- | - `append` +-- | - Merge two `JSX` nodes using `React.Fragment`. +-- | - `mempty` +-- | - The `empty` node; renders nothing. +-- | +-- | __*Hint:* Many useful utility functions already exist for Monoids. For example, +-- | `guard` can be used to conditionally render a subtree of components.__ foreign import data JSX :: Type instance semigroupJSX :: Semigroup JSX where @@ -27,155 +337,170 @@ instance semigroupJSX :: Semigroup JSX where instance monoidJSX :: Monoid JSX where mempty = empty --- | A React component which can be used from JavaScript. -foreign import data Component :: Type -> Type - --- | Create a React component from a _specification_ of that component. --- | --- | A _specification_ consists of a state type, an initial value for that state, --- | a function to apply incoming props to the internal state, and a rendering --- | function which takes props, state and a state update function. --- | --- | The rendering function should return a value of type `JSX`, which can be --- | constructed using the helper functions provided by the `React.Basic.DOM` --- | module. --- | --- | Note: This function relies on `React.PureComponent` internally -component - :: forall props state - . { displayName :: String - , initialState :: { | state } - , receiveProps :: - { isFirstMount :: Boolean - , props :: { | props } - , state :: { | state } - , setState :: SetState state - , setStateThen :: SetStateThen state - , instance_ :: ComponentInstance - } - -> Effect Unit - , render :: - { props :: { | props } - , state :: { | state } - , setState :: SetState state - , setStateThen :: SetStateThen state - , instance_ :: ComponentInstance - } - -> JSX - } - -> Component { | props } -component { displayName, initialState, receiveProps, render } = - component_ - { displayName - , initialState - , receiveProps: mkEffectFn1 \this -> receiveProps - { isFirstMount: this.isFirstMount - , props: this.props - , state: this.state - , setState: runEffectFn1 this.setState - , setStateThen: \update cb -> runEffectFn2 this.setStateThen update (mkEffectFn1 cb) - , instance_: this.instance_ - } - , render: mkFn1 \this -> render - { props: this.props - , state: this.state - , setState: runEffectFn1 this.setState - , setStateThen: \update cb -> runEffectFn2 this.setStateThen update (mkEffectFn1 cb) - , instance_: this.instance_ - } - } - --- | Create a stateless React component. +-- | An empty `JSX` node. This is often useful when you would like to conditionally +-- | show something, but you don't want to (or can't) modify the `children` prop +-- | on the parent node. -- | --- | Removes a little bit of the `react` function's boilerplate when creating --- | components which don't use state. -stateless - :: forall props - . { displayName :: String - , render :: { | props } -> JSX - } - -> Component { | props } -stateless { displayName, render } = - component - { displayName - , initialState: {} - , receiveProps: \_ -> pure unit - , render: \this -> render this.props - } - --- | SetState uses an update function to modify the current state. -type SetState state = ({ | state } -> { | state }) -> Effect Unit +-- | __*See also:* `JSX`, Monoid `guard`__ +foreign import empty :: JSX --- | SetState uses an update function to modify the current state and a --- | callback to invoke once the resulting rerender has been completely applied. -type SetStateThen state = ({ | state } -> { | state }) -> ({ | state } -> Effect Unit) -> Effect Unit +-- | Apply a React key to a subtree. React-Basic usually hides React's warning about +-- | using `key` props on components in an Array, but keys are still important for +-- | any dynamic lists of child components. +-- | +-- | __*See also:* React's documentation regarding the special `key` prop__ +keyed :: String -> JSX -> JSX +keyed = runFn2 keyed_ --- | Represents the mounted component instance, or "this" in vanilla React. -foreign import data ComponentInstance :: Type +-- | Render an Array of children without a wrapping component. +-- | +-- | __*See also:* `JSX`__ +foreign import fragment :: Array JSX -> JSX --- | Create a `JSX` node from a React component, by providing the props. +-- | Create a `JSX` node from a `ReactComponent`, by providing the props. +-- | +-- | This function is for non-React-Basic React components, such as those +-- | imported from FFI. +-- | +-- | __*See also:* `ReactComponent`, `elementKeyed`__ element :: forall props - . Component { | props } + . ReactComponent { | props } -> { | props } -> JSX element = runFn2 element_ --- | Like `element`, plus a `key` for rendering components in a dynamic list. --- | For more information see: https://reactjs.org/docs/reconciliation.html#keys +-- | Create a `JSX` node from a `ReactComponent`, by providing the props and a key. +-- | +-- | This function is for non-React-Basic React components, such as those +-- | imported from FFI. +-- | +-- | __*See also:* `ReactComponent`, `element`, React's documentation regarding the special `key` prop__ elementKeyed :: forall props - . Component { | props } + . ReactComponent { | props } -> { key :: String | props } -> JSX elementKeyed = runFn2 elementKeyed_ --- | An empty node. This is often useful when you would like to conditionally --- | show something, but you don't want to (or can't) modify the `children` prop --- | on the parent node. -empty :: JSX -empty = unsafeCoerce false +-- | Retrieve the Display Name from a `ComponentSpec`. Useful for debugging and improving +-- | error messages in logs. +-- | +-- | __*See also:* `displayNameFromSelf`, `createComponent`__ +foreign import displayNameFromComponent + :: forall props + . Component props + -> String --- | Render an Array of children without a wrapping component. -foreign import fragment :: Array JSX -> JSX +-- | Retrieve the Display Name from a `Self`. Useful for debugging and improving +-- | error messages in logs. +-- | +-- | __*See also:* `displayNameFromComponent`, `createComponent`__ +foreign import displayNameFromSelf + :: forall props state action + . Self props state action + -> String --- | Render an Array of children without a wrapping component. +-- | Represents a traditional React component. Useful for JavaScript interop and +-- | FFI. For example: +-- | +-- | ```purs +-- | foreign import ComponentRequiringJSHacks :: ReactComponent { someProp :: String } +-- | ``` -- | --- | Provide a key when dynamically rendering multiple fragments along side --- | each other. -fragmentKeyed :: String -> Array JSX -> JSX -fragmentKeyed = runFn2 fragmentKeyed_ - --- | Private FFI - -foreign import component_ - :: forall props state - . { displayName :: String - , initialState :: { | state } - , receiveProps :: - EffectFn1 - { isFirstMount :: Boolean - , props :: { | props } - , state :: { | state } - , setState :: EffectFn1 ({ | state } -> { | state }) Unit - , setStateThen :: EffectFn2 ({ | state } -> { | state }) (EffectFn1 { | state } Unit) Unit - , instance_ :: ComponentInstance - } - Unit - , render :: - Fn1 - { props :: { | props } - , state :: { | state } - , setState :: EffectFn1 ({ | state } -> { | state }) Unit - , setStateThen :: EffectFn2 ({ | state } -> { | state }) (EffectFn1 { | state } Unit) Unit - , instance_ :: ComponentInstance - } - JSX +-- | __*See also:* `element`, `toReactComponent`__ +data ReactComponent props + +-- | An opaque representation of a React component's instance (`this` in the JavaScript +-- | React paradigm). It exists as an escape hatch to unsafe behavior. Use it with +-- | caution. +data ReactComponentInstance + +-- | Convert a React-Basic `ComponentSpec` to a JavaScript-friendly React component. +-- | This function should only be used for JS interop and not normal React-Basic usage. +-- | +-- | __*Note:* Like `createComponent`, `toReactComponent` is side effecting in that +-- | it creates a "class" React will see as unique each time it's called. Lift +-- | any usage up to the module level, usage in `render` or any other function, +-- | and applying any type classes to the `props`.__ +-- | +-- | __*See also:* `ReactComponent`__ +foreign import toReactComponent + :: forall spec spec_ jsProps props state action + . Union spec spec_ (ComponentSpec props state action) + => ({ | jsProps } -> props) + -> Component props + -> { render :: Self props state action -> JSX | spec } + -> ReactComponent { | jsProps } + + +-- | +-- | Internal utility or FFI functions +-- | + +buildStateUpdate + :: forall props state action + . StateUpdate props state action + -> { state :: Nullable state + , effects :: Nullable (Self props state action -> Effect Unit) } - -> Component { | props } +buildStateUpdate = case _ of + NoUpdate -> + { state: null + , effects: null + } + Update state_ -> + { state: notNull state_ + , effects: null + } + SideEffects effects -> + { state: null + , effects: notNull effects + } + UpdateAndSideEffects state_ effects -> + { state: notNull state_ + , effects: notNull effects + } + +foreign import send_ + :: forall props state action + . Fn1 + (StateUpdate props state action + -> { state :: Nullable state + , effects :: Nullable (Self props state action -> Effect Unit) + }) + (EffectFn2 + (Self props state action) + action + Unit) + +foreign import keyed_ :: Fn2 String JSX JSX + +foreign import element_ + :: forall props + . Fn2 (ReactComponent { | props }) { | props } JSX + +foreign import elementKeyed_ + :: forall props + . Fn2 (ReactComponent { | props }) { key :: String | props } JSX -foreign import element_ :: forall props. Fn2 (Component { | props }) { | props } JSX +foreign import warningDefaultUpdate + :: forall props state action + . EffectFn2 + (Self props state action) + action + Unit -foreign import elementKeyed_ :: forall props. Fn2 (Component { | props }) { key :: String | props } JSX +foreign import warningUnmountedComponentAction + :: forall props state action + . EffectFn2 + (Self props state action) + action + Unit -foreign import fragmentKeyed_ :: Fn2 String (Array JSX) JSX +foreign import warningFailedAsyncAction + :: forall props state action + . EffectFn2 + (Self props state action) + Error + Unit diff --git a/src/React/Basic/DOM.purs b/src/React/Basic/DOM.purs index 53fa16d..85add7e 100644 --- a/src/React/Basic/DOM.purs +++ b/src/React/Basic/DOM.purs @@ -29,7 +29,7 @@ import Data.Nullable (Nullable, toMaybe) import Effect (Effect) import Effect.Exception (Error, throw, try) import Effect.Uncurried (EffectFn1, EffectFn3, runEffectFn1, runEffectFn3) -import React.Basic (ComponentInstance, JSX) +import React.Basic (ReactComponentInstance, JSX) import React.Basic.DOM.Generated (Props_a, Props_abbr, Props_address, Props_area, Props_article, Props_aside, Props_audio, Props_b, Props_base, Props_bdi, Props_bdo, Props_blockquote, Props_body, Props_br, Props_button, Props_canvas, Props_caption, Props_cite, Props_code, Props_col, Props_colgroup, Props_data, Props_datalist, Props_dd, Props_del, Props_details, Props_dfn, Props_dialog, Props_div, Props_dl, Props_dt, Props_em, Props_embed, Props_fieldset, Props_figcaption, Props_figure, Props_footer, Props_form, Props_h1, Props_h2, Props_h3, Props_h4, Props_h5, Props_h6, Props_head, Props_header, Props_hgroup, Props_hr, Props_html, Props_i, Props_iframe, Props_img, Props_input, Props_ins, Props_kbd, Props_keygen, Props_label, Props_legend, Props_li, Props_link, Props_main, Props_map, Props_mark, Props_math, Props_menu, Props_menuitem, Props_meta, Props_meter, Props_nav, Props_noscript, Props_object, Props_ol, Props_optgroup, Props_option, Props_output, Props_p, Props_param, Props_picture, Props_pre, Props_progress, Props_q, Props_rb, Props_rp, Props_rt, Props_rtc, Props_ruby, Props_s, Props_samp, Props_script, Props_section, Props_select, Props_slot, Props_small, Props_source, Props_span, Props_strong, Props_style, Props_sub, Props_summary, Props_sup, Props_svg, Props_table, Props_tbody, Props_td, Props_template, Props_textarea, Props_tfoot, Props_th, Props_thead, Props_time, Props_title, Props_tr, Props_track, Props_u, Props_ul, Props_var, Props_video, Props_wbr, a, a_, abbr, abbr_, address, address_, area, article, article_, aside, aside_, audio, audio_, b, b_, base, bdi, bdi_, bdo, bdo_, blockquote, blockquote_, body, body_, br, button, button_, canvas, canvas_, caption, caption_, cite, cite_, code, code_, col, colgroup, colgroup_, data', data_, datalist, datalist_, dd, dd_, del, del_, details, details_, dfn, dfn_, dialog, dialog_, div, div_, dl, dl_, dt, dt_, em, em_, embed, fieldset, fieldset_, figcaption, figcaption_, figure, figure_, footer, footer_, form, form_, h1, h1_, h2, h2_, h3, h3_, h4, h4_, h5, h5_, h6, h6_, head, head_, header, header_, hgroup, hgroup_, hr, html, html_, i, i_, iframe, iframe_, img, input, ins, ins_, kbd, kbd_, keygen, keygen_, label, label_, legend, legend_, li, li_, link, main, main_, map, map_, mark, mark_, math, math_, menu, menu_, menuitem, menuitem_, meta, meter, meter_, nav, nav_, noscript, noscript_, object, object_, ol, ol_, optgroup, optgroup_, option, option_, output, output_, p, p_, param, picture, picture_, pre, pre_, progress, progress_, q, q_, rb, rb_, rp, rp_, rt, rt_, rtc, rtc_, ruby, ruby_, s, s_, samp, samp_, script, script_, section, section_, select, select_, slot, slot_, small, small_, source, span, span_, strong, strong_, style, style_, sub, sub_, summary, summary_, sup, sup_, svg, svg_, table, table_, tbody, tbody_, td, td_, template, template_, textarea, textarea_, tfoot, tfoot_, th, th_, thead, thead_, time, time_, title, title_, tr, tr_, track, u, u_, ul, ul_, var, var_, video, video_, wbr) as Generated import React.Basic.DOM.Internal (CSS, SharedProps, unsafeCreateDOMComponent) as Internal import Unsafe.Coerce (unsafeCoerce) @@ -90,7 +90,7 @@ foreign import unmountComponentAtNode_ :: EffectFn1 Element Boolean -- | instance was not mounted. -- | -- | Note: Relies on `ReactDOM.findDOMNode` -findDOMNode :: ComponentInstance -> Effect (Either Error Node) +findDOMNode :: ReactComponentInstance -> Effect (Either Error Node) findDOMNode instance_ = try do node <- runEffectFn1 findDOMNode_ instance_ case toMaybe node of @@ -98,7 +98,7 @@ findDOMNode instance_ = try do Just n -> pure n -- | Warning: Relies on `ReactDOM.findDOMNode` which may throw exceptions -foreign import findDOMNode_ :: EffectFn1 ComponentInstance (Nullable Node) +foreign import findDOMNode_ :: EffectFn1 ReactComponentInstance (Nullable Node) -- | Divert a render tree into a separate DOM node. The node's -- | content will be overwritten and managed by React, similar diff --git a/src/React/Basic/DOM/Internal.purs b/src/React/Basic/DOM/Internal.purs index c3b6240..156dd44 100644 --- a/src/React/Basic/DOM/Internal.purs +++ b/src/React/Basic/DOM/Internal.purs @@ -1,6 +1,6 @@ module React.Basic.DOM.Internal where -import React.Basic (Component) +import React.Basic (ReactComponent) import React.Basic.Events (EventHandler) import Unsafe.Coerce (unsafeCoerce) @@ -93,5 +93,5 @@ type SharedProps specific = | specific ) -unsafeCreateDOMComponent :: forall props. String -> Component props +unsafeCreateDOMComponent :: forall props. String -> ReactComponent props unsafeCreateDOMComponent = unsafeCoerce