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/README.md b/README.md index be55df5..ca45c7c 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,16 @@ [![Build Status](https://travis-ci.org/lumihq/purescript-react-basic.svg?branch=master)](https://travis-ci.org/lumihq/purescript-react-basic) -This package implements an opinionated set of bindings to the React library, optimizing for the most basic use cases. +This package implements an opinionated set of bindings over [React](https://reactjs.org), optimizing for correctness and simplifying basic use cases. ## Features - All React DOM elements and attributes are supported (soon, events are a work in progress). -- An intuitive API for specifying props - no arrays of key value pairs, just records. +- An intuitive API for specifying props - simple records, no arrays of key value pairs. - Attributes are optional, but type-checked. It is a type error to specify `href` as an integer, for example. +- An action/update pattern for local component state, inspired by [ReasonReact](https://reasonml.github.io/reason-react/). +- React lifecycles are available, but not in your way when you don't need them. +- Typeclasses, like `Eq props`, can be used in component definitions. ## Getting Started @@ -18,49 +21,12 @@ You can install this package using Bower: bower install git@github.com:lumihq/purescript-react-basic.git ``` -Here is an example component which renders a label read from props along with a counter: +See [the documentation](https://pursuit.purescript.org/packages/purescript-react-basic/docs/React.Basic) for a detailed overview, or take a look at one of the examples: -```purescript -module Counter where +- [A Counter](./examples/counter/src/Counter.purs) +- [A controlled input](./examples/controlled-input/src/ControlledInput.purs) +- [Components](./examples/component/src/ToggleButton.purs) in [components](./examples/component/src/Container.purs) -import Prelude +## Migrating to v4 from v2 or v3 -import React.Basic as React -import React.Basic.DOM as R -import React.Basic.Events as Events - --- The props for the component -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 } - where - initialState = - { counter: 0 - } - - receiveProps _ = - pure unit - - render { props, state, setState } = - R.button - { onClick: Events.handler_ do - setState \s -> s { counter = s.counter + 1 } - , children: [ R.text (props.label <> ": " <> show state.counter) ] - } -``` - -This component can be used directly from JavaScript. For example, if you are using `purs-loader`: - -```jsx -import {example as Example} from 'React.Basic.Example.purs'; - -const myComponent = () => ( - -); -``` +v4 includes a new (but deprecated) module, `React.Basic.Compat`. It matches most of the old API and types (except `setStateThen` and `isFirstMount`) to make upgrading easier and more gradual. You can find `^import\sReact\.Basic\b` and replace with `import React.Basic.Compat`, upgrade the package version, and proceed from there one component at a time (or only new components). See the documentation link above for more info on the v4 API. 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..07c0ba3 100644 --- a/examples/counter/src/Counter.purs +++ b/examples/counter/src/Counter.purs @@ -2,31 +2,30 @@ 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 } - 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/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..4632e99 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 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) +``` + +`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 +and 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 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. `make` will validate whether +your component's internal 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`__ + +#### `createComponent` + +``` purescript +createComponent :: forall props. String -> Component props +``` + +Creates a `Component` 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 becomes possible for incompatible prop values to + be passed by React into lifecycle functions.__ + +__*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`, `make`, `makeStateless`__ + #### `Component` ``` purescript -data Component :: Type -> Type +data Component props ``` -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`. +Opaque component information for internal use. + +__*Note:* Never define a component with + a less specific type for `props` than its associated `ComponentSpec` and `make` + calls, as this can lead to unsafe interactions with React's lifecycle management.__ -#### `ComponentInstance` +__*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 ComponentInstance :: Type +data StateUpdate props state action + = NoUpdate + | Update state + | SideEffects (Self props state action -> Effect Unit) + | UpdateAndSideEffects state (Self props state action -> Effect Unit) ``` -Represents the mounted component instance, or "this" in vanilla React. +Used by the `update` function to describe the kind of state update and/or side +effects desired. -#### `JSX` +__*See also:* `ComponentSpec`, `capture`__ + +#### `Self` ``` purescript -data JSX :: Type +type Self props state action = { props :: props, state :: state, instance_ :: ReactComponentInstance } ``` -A virtual DOM element. +`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` -##### Instances ``` purescript -Semigroup JSX -Monoid JSX +send :: forall props state action. Self props state action -> action -> Effect Unit +``` + +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 ``` -#### `component` +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`__ -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. +__*See also:* `update`, `capture_`, `monitor`, `React.Basic.Events`__ -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. +#### `capture_` -Note: This function relies on `React.PureComponent` internally +``` purescript +capture_ :: forall props state action. Self props state action -> action -> EventHandler +``` + +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 spec spec_ props state action. Union spec spec_ (ComponentSpec props state action) => Component props -> { render :: Self props state action -> JSX | spec } -> props -> JSX +``` + +Turn a `Component` and `ComponentSpec` 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`__ + +#### `makeStateless` ``` purescript -elementKeyed :: forall props. Component { | props } -> { key :: String | props } -> JSX +makeStateless :: forall props. Component props -> (props -> JSX) -> props -> JSX +``` + +Makes stateless component definition slightly less verbose: + +```purs +component :: Component Props +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,95 @@ 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__ + +#### `displayNameFromComponent` + +``` purescript +displayNameFromComponent :: forall props. Component props -> String +``` + +Retrieve the Display Name from a `ComponentSpec`. Useful for debugging and improving +error messages in logs. + +__*See also:* `displayNameFromSelf`, `createComponent`__ + +#### `displayNameFromSelf` + +``` purescript +displayNameFromSelf :: forall props state action. Self props state action -> String +``` + +Retrieve the Display Name from a `Self`. Useful for debugging and improving +error messages in logs. + +__*See also:* `displayNameFromComponent`, `createComponent`__ + +#### `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 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 } +``` + +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/Compat.md b/generated-docs/React/Basic/Compat.md new file mode 100644 index 0000000..0137610 --- /dev/null +++ b/generated-docs/React/Basic/Compat.md @@ -0,0 +1,112 @@ +## Module React.Basic.Compat + +#### `Component` + +``` purescript +type Component = ReactComponent +``` + +#### `component` + +``` purescript +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 } +``` + +Supports a common subset of the v2 API to ease the upgrade process + +#### `stateless` + +``` purescript +stateless :: forall props. { displayName :: String, render :: { | props } -> JSX } -> ReactComponent { | props } +``` + +Supports a common subset of the v2 API to ease the upgrade process + + +### Re-exported from React.Basic: + +#### `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 +``` + +#### `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 +fragment :: Array JSX -> JSX +``` + +Render an Array of children without a wrapping component. + +__*See also:* `JSX`__ + +#### `empty` + +``` purescript +empty :: JSX +``` + +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`__ + +#### `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__ + +#### `element` + +``` purescript +element :: forall props. ReactComponent { | props } -> { | props } -> JSX +``` + +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`__ + diff --git a/generated-docs/React/Basic/DOM.md b/generated-docs/React/Basic/DOM.md index b36435f..11123e9 100644 --- a/generated-docs/React/Basic/DOM.md +++ b/generated-docs/React/Basic/DOM.md @@ -3,9 +3,9 @@ This module defines helper functions for creating virtual DOM elements safely. -Note: DOM element props are provided as records, and checked using `Union` -constraints. This means that we don't need to provide all props, but any we -do provide must have the correct types. +__*Note:* DOM element props are provided as records, and checked using `Union` + constraints. This means that we don't need to provide all props, but any we + do provide must have the correct types.__ #### `render` @@ -16,7 +16,7 @@ render :: JSX -> Element -> Effect Unit Render or update/re-render a component tree into a DOM element. -Note: Relies on `ReactDOM.render` +__*Note:* Relies on `ReactDOM.render`__ #### `render'` @@ -28,7 +28,7 @@ Render or update/re-render a component tree into a DOM element. The given Effect is run once the DOM update is complete. -Note: Relies on `ReactDOM.render` +__*Note:* Relies on `ReactDOM.render`__ #### `hydrate` @@ -40,9 +40,9 @@ Render or update/re-render a component tree into a DOM element, attempting to reuse the existing DOM tree. -Note: Relies on `ReactDOM.hydrate`, generally only +__*Note:* Relies on `ReactDOM.hydrate`, generally only used with `ReactDOMServer.renderToNodeStream` or - `ReactDOMServer.renderToString` + `ReactDOMServer.renderToString`__ #### `hydrate'` @@ -55,9 +55,9 @@ a DOM element, attempting to reuse the existing DOM tree. The given Effect is run once the DOM update is complete. -Note: Relies on `ReactDOM.hydrate`, generally only +__*Note:* Relies on `ReactDOM.hydrate`, generally only used with `ReactDOMServer.renderToNodeStream` or - `ReactDOMServer.renderToString` + `ReactDOMServer.renderToString`__ #### `unmount` @@ -69,19 +69,22 @@ Attempt to unmount and clean up the React app rendered into the given element. Returns `true` if an app existed and was unmounted successfully. -Note: Relies on `ReactDOM.unmountComponentAtNode` +__*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 instance, or an Error if no node was found or the given instance was not mounted. -Note: Relies on `ReactDOM.findDOMNode` +__*Note:* This function can be *very* slow -- prefer +`React.Basic.DOM.Components.Ref` where possible__ + +__*Note:* Relies on `ReactDOM.findDOMNode`__ #### `createPortal` @@ -110,7 +113,7 @@ css :: forall css. { | css } -> CSS Create a value of type `CSS` (which can be provided to the `style` property) from a plain record of CSS attributes. -E.g. +For example: ``` div { style: css { padding: "5px" } } [ text "This text is padded." ] @@ -124,7 +127,7 @@ mergeStyles :: Array CSS -> CSS Merge styles from right to left. Uses `Object.assign`. -E.g. +For example: ``` style: mergeCSS [ (css { padding: "5px" }), props.style ] @@ -2194,6 +2197,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/Components/GlobalEvents.md b/generated-docs/React/Basic/DOM/Components/GlobalEvents.md new file mode 100644 index 0000000..bb945fa --- /dev/null +++ b/generated-docs/React/Basic/DOM/Components/GlobalEvents.md @@ -0,0 +1,64 @@ +## Module React.Basic.DOM.Components.GlobalEvents + +These helper components register and unregister event callbacks +using React's the lifecycle callbacks. They're useful for +declaratively defining global behavior which is associated with +a particular component being mounted without having to wire +all that lifecycle logic up manually. + +For example: + +```purs +render self = + R.div + { className: "dropdown-wrapper" + , children: + [ dropdownButton + , guard showDropdown $ + windowEvent + { eventType: EventType "click" + , options: defaultOptions + , handler: \_ -> send self CloseDropdown + } + dropdownMenu + ] + } +``` + +#### `EventHandlerOptions` + +``` purescript +type EventHandlerOptions = { capture :: Boolean, once :: Boolean, passive :: Boolean } +``` + +#### `defaultOptions` + +``` purescript +defaultOptions :: EventHandlerOptions +``` + +#### `globalEvent` + +``` purescript +globalEvent :: EventTarget -> { eventType :: EventType, options :: EventHandlerOptions, handler :: Event -> Effect Unit } -> JSX -> JSX +``` + +#### `globalEvents` + +``` purescript +globalEvents :: EventTarget -> Array { eventType :: EventType, options :: EventHandlerOptions, handler :: Event -> Effect Unit } -> JSX -> JSX +``` + +#### `windowEvent` + +``` purescript +windowEvent :: { eventType :: EventType, options :: EventHandlerOptions, handler :: Event -> Effect Unit } -> JSX -> JSX +``` + +#### `windowEvents` + +``` purescript +windowEvents :: Array { eventType :: EventType, options :: EventHandlerOptions, handler :: Event -> Effect Unit } -> JSX -> JSX +``` + + diff --git a/generated-docs/React/Basic/DOM/Components/LogLifecycles.md b/generated-docs/React/Basic/DOM/Components/LogLifecycles.md new file mode 100644 index 0000000..be1c32c --- /dev/null +++ b/generated-docs/React/Basic/DOM/Components/LogLifecycles.md @@ -0,0 +1,9 @@ +## Module React.Basic.DOM.Components.LogLifecycles + +#### `logLifecycles` + +``` purescript +logLifecycles :: Warn (Text "LogLifecycle is for debugging purposes only. Don't forget to remove it!") => JSX -> JSX +``` + + diff --git a/generated-docs/React/Basic/DOM/Components/Ref.md b/generated-docs/React/Basic/DOM/Components/Ref.md new file mode 100644 index 0000000..75b4bef --- /dev/null +++ b/generated-docs/React/Basic/DOM/Components/Ref.md @@ -0,0 +1,45 @@ +## Module React.Basic.DOM.Components.Ref + +This module provides an efficient (no `ReactDOM.findDOMNode`) and +declarative way to aquire a `Node` for an element in your render +tree. + +For example: + +```purs +render self = + ref \myRef -> + case myRef of + Nothing -> R.text "First DOM render in progress..." + Just _ -> R.text "First DOM render complete." +``` + +#### `ref` + +``` purescript +ref :: (Maybe Node -> JSX) -> JSX +``` + +#### `selectorRef` + +``` purescript +selectorRef :: QuerySelector -> (Maybe Node -> JSX) -> JSX +``` + + +### Re-exported from Web.DOM.ParentNode: + +#### `QuerySelector` + +``` purescript +newtype QuerySelector + = QuerySelector String +``` + +##### Instances +``` purescript +Eq QuerySelector +Ord QuerySelector +Newtype QuerySelector _ +``` + 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..8252eb8 100644 --- a/src/React/Basic.purs +++ b/src/React/Basic.purs @@ -1,24 +1,337 @@ 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 +-- | and 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 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. `make` will validate whether +-- | your component's internal 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 `Component` 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 becomes possible for incompatible prop values to +-- | be passed by React into lifecycle functions.__ +-- | +-- | __*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`, `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. +-- | +-- | __*Note:* Never define a component with +-- | a less specific type for `props` than its associated `ComponentSpec` and `make` +-- | calls, as this can lead to unsafe interactions with React's lifecycle management.__ +-- | +-- | __*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 --- | A virtual DOM element. +-- | Used by the `update` function to describe the kind of state update and/or side +-- | effects desired. +-- | +-- | __*See also:* `ComponentSpec`, `capture`__ +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` and `ComponentSpec` 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 + +-- | 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 +340,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/Compat.purs b/src/React/Basic/Compat.purs new file mode 100644 index 0000000..f450a45 --- /dev/null +++ b/src/React/Basic/Compat.purs @@ -0,0 +1,50 @@ +module React.Basic.Compat + ( Component + , component + , stateless + , module CompatibleTypes + ) where + +import Prelude + +import Effect (Effect) +import React.Basic (ReactComponent, StateUpdate(..), createComponent, send, toReactComponent) +import React.Basic (JSX, element, elementKeyed, empty, fragment, keyed) as CompatibleTypes + +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 } -> CompatibleTypes.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 } -> CompatibleTypes.JSX + } + -> ReactComponent { | props } +stateless { displayName, render } = + toReactComponent identity (createComponent displayName) + { render: \self -> render self.props + } diff --git a/src/React/Basic/DOM.purs b/src/React/Basic/DOM.purs index 53fa16d..ca45370 100644 --- a/src/React/Basic/DOM.purs +++ b/src/React/Basic/DOM.purs @@ -1,9 +1,9 @@ -- | This module defines helper functions for creating virtual DOM elements -- | safely. -- | --- | Note: DOM element props are provided as records, and checked using `Union` --- | constraints. This means that we don't need to provide all props, but any we --- | do provide must have the correct types. +-- | __*Note:* DOM element props are provided as records, and checked using `Union` +-- | constraints. This means that we don't need to provide all props, but any we +-- | do provide must have the correct types.__ module React.Basic.DOM ( module Internal @@ -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) @@ -38,7 +38,7 @@ import Web.DOM (Element, Node) -- | Render or update/re-render a component tree into -- | a DOM element. -- | --- | Note: Relies on `ReactDOM.render` +-- | __*Note:* Relies on `ReactDOM.render`__ render :: JSX -> Element -> Effect Unit render jsx node = render' jsx node (pure unit) @@ -46,7 +46,7 @@ render jsx node = render' jsx node (pure unit) -- | a DOM element. The given Effect is run once the -- | DOM update is complete. -- | --- | Note: Relies on `ReactDOM.render` +-- | __*Note:* Relies on `ReactDOM.render`__ render' :: JSX -> Element -> Effect Unit -> Effect Unit render' = runEffectFn3 render_ @@ -56,9 +56,9 @@ foreign import render_ :: EffectFn3 JSX Element (Effect Unit) Unit -- | a DOM element, attempting to reuse the existing -- | DOM tree. -- | --- | Note: Relies on `ReactDOM.hydrate`, generally only +-- | __*Note:* Relies on `ReactDOM.hydrate`, generally only -- | used with `ReactDOMServer.renderToNodeStream` or --- | `ReactDOMServer.renderToString` +-- | `ReactDOMServer.renderToString`__ hydrate :: JSX -> Element -> Effect Unit hydrate jsx node = hydrate' jsx node (pure unit) @@ -67,9 +67,9 @@ hydrate jsx node = hydrate' jsx node (pure unit) -- | DOM tree. The given Effect is run once the -- | DOM update is complete. -- | --- | Note: Relies on `ReactDOM.hydrate`, generally only +-- | __*Note:* Relies on `ReactDOM.hydrate`, generally only -- | used with `ReactDOMServer.renderToNodeStream` or --- | `ReactDOMServer.renderToString` +-- | `ReactDOMServer.renderToString`__ hydrate' :: JSX -> Element -> Effect Unit -> Effect Unit hydrate' = runEffectFn3 hydrate_ @@ -79,7 +79,7 @@ foreign import hydrate_ :: EffectFn3 JSX Element (Effect Unit) Unit -- | rendered into the given element. Returns `true` -- | if an app existed and was unmounted successfully. -- | --- | Note: Relies on `ReactDOM.unmountComponentAtNode` +-- | __*Note:* Relies on `ReactDOM.unmountComponentAtNode`__ unmount :: Element -> Effect Boolean unmount = runEffectFn1 unmountComponentAtNode_ @@ -89,8 +89,11 @@ foreign import unmountComponentAtNode_ :: EffectFn1 Element Boolean -- | instance, or an Error if no node was found or the given -- | instance was not mounted. -- | --- | Note: Relies on `ReactDOM.findDOMNode` -findDOMNode :: ComponentInstance -> Effect (Either Error Node) +-- | __*Note:* This function can be *very* slow -- prefer +-- | `React.Basic.DOM.Components.Ref` where possible__ +-- | +-- | __*Note:* Relies on `ReactDOM.findDOMNode`__ +findDOMNode :: ReactComponentInstance -> Effect (Either Error Node) findDOMNode instance_ = try do node <- runEffectFn1 findDOMNode_ instance_ case toMaybe node of @@ -98,7 +101,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 @@ -115,7 +118,7 @@ text = unsafeCoerce -- | Create a value of type `CSS` (which can be provided to the `style` property) -- | from a plain record of CSS attributes. -- | --- | E.g. +-- | For example: -- | -- | ``` -- | div { style: css { padding: "5px" } } [ text "This text is padded." ] @@ -125,7 +128,7 @@ css = unsafeCoerce -- | Merge styles from right to left. Uses `Object.assign`. -- | --- | E.g. +-- | For example: -- | -- | ``` -- | style: mergeCSS [ (css { padding: "5px" }), props.style ] diff --git a/src/React/Basic/DOM/Components/GlobalEvents.js b/src/React/Basic/DOM/Components/GlobalEvents.js new file mode 100644 index 0000000..0e0d649 --- /dev/null +++ b/src/React/Basic/DOM/Components/GlobalEvents.js @@ -0,0 +1,90 @@ +"use strict"; + +var React = require("react"); + +exports._passiveSupported = null; + +function checkPassiveSupported() { + if (exports._passiveSupported == null) { + try { + window.addEventListener( + "test", + null, + Object.defineProperty({}, "passive", { + get: function() { + exports._passiveSupported = true; + } + }) + ); + } catch (err) { + exports._passiveSupported = false; + } + } + return exports._passiveSupported; +} + +function createHandler(h) { + return function(e) { + return h(e)(); + }; +} + +function up(target, eventType, handler, options) { + target.addEventListener( + eventType, + handler, + checkPassiveSupported() ? options : options.capture + ); +} + +function down(target, eventType, handler, options) { + target.removeEventListener( + eventType, + handler, + checkPassiveSupported() ? options : options.capture + ); +} + +var GlobalEvent = function() { + return this; +}; + +GlobalEvent.prototype = Object.create(React.Component.prototype); + +GlobalEvent.displayName = "GlobalEvent"; + +GlobalEvent.prototype.componentDidMount = function() { + this._handler = createHandler(this.props.handler); + up( + this.props.target, + this.props.eventType, + this._handler, + this.props.options + ); +}; + +GlobalEvent.prototype.componentDidUpdate = function(prevProps) { + down(prevProps.target, prevProps.eventType, this._handler, prevProps.options); + this._handler = createHandler(this.props.handler); + up( + this.props.target, + this.props.eventType, + this._handler, + this.props.options + ); +}; + +GlobalEvent.prototype.componentWillUnmount = function() { + down( + this.props.target, + this.props.eventType, + this._handler, + this.props.options + ); +}; + +GlobalEvent.prototype.render = function() { + return this.props.child; +}; + +exports.globalEvent_ = GlobalEvent; diff --git a/src/React/Basic/DOM/Components/GlobalEvents.purs b/src/React/Basic/DOM/Components/GlobalEvents.purs new file mode 100644 index 0000000..ea9081d --- /dev/null +++ b/src/React/Basic/DOM/Components/GlobalEvents.purs @@ -0,0 +1,117 @@ +-- | These helper components register and unregister event callbacks +-- | using React's the lifecycle callbacks. They're useful for +-- | declaratively defining global behavior which is associated with +-- | a particular component being mounted without having to wire +-- | all that lifecycle logic up manually. +-- | +-- | For example: +-- | +-- | ```purs +-- | render self = +-- | R.div +-- | { className: "dropdown-wrapper" +-- | , children: +-- | [ dropdownButton +-- | , guard showDropdown $ +-- | windowEvent +-- | { eventType: EventType "click" +-- | , options: defaultOptions +-- | , handler: \_ -> send self CloseDropdown +-- | } +-- | dropdownMenu +-- | ] +-- | } +-- | ``` +module React.Basic.DOM.Components.GlobalEvents + ( EventHandlerOptions + , defaultOptions + , globalEvent + , globalEvents + , windowEvent + , windowEvents + ) where + +import Prelude + +import Data.Foldable (foldr) +import Effect (Effect) +import Effect.Unsafe (unsafePerformEffect) +import React.Basic (JSX, ReactComponent, element) +import Web.Event.Event (EventType) +import Web.Event.Internal.Types (Event, EventTarget) +import Web.HTML (window) +import Web.HTML.Window as Window + +type EventHandlerOptions = + { capture :: Boolean + , once :: Boolean + , passive :: Boolean + } + +defaultOptions :: EventHandlerOptions +defaultOptions = + { capture: false + , once: false + , passive: false + } + +foreign import globalEvent_ + :: ReactComponent + { target :: EventTarget + , eventType :: EventType + , handler :: Event -> Effect Unit + , options :: EventHandlerOptions + , child :: JSX + } + +globalEvent + :: EventTarget + -> { eventType :: EventType + , options :: EventHandlerOptions + , handler :: Event -> Effect Unit + } + -> JSX + -> JSX +globalEvent target { eventType, options, handler } child = + element globalEvent_ + { target + , eventType + , handler + , options + , child + } + +globalEvents + :: EventTarget + -> Array + { eventType :: EventType + , options :: EventHandlerOptions + , handler :: Event -> Effect Unit + } + -> JSX + -> JSX +globalEvents target = flip (foldr (globalEvent target)) + +windowEvents + :: Array + { eventType :: EventType + , options :: EventHandlerOptions + , handler :: Event -> Effect Unit + } + -> JSX + -> JSX +windowEvents = globalEvents $ unsafePerformEffect $ map Window.toEventTarget window + +windowEvent + :: { eventType :: EventType + , options :: EventHandlerOptions + , handler :: Event -> Effect Unit + } + -> JSX + -> JSX +windowEvent = windowEvents <<< pure + +-- | Hide "unused ffi export" warning. +-- | The export is required to prevent +-- | PS' bundler from stripping it out. +foreign import _passiveSupported :: Void diff --git a/src/React/Basic/DOM/Components/LogLifecycles.js b/src/React/Basic/DOM/Components/LogLifecycles.js new file mode 100644 index 0000000..714397d --- /dev/null +++ b/src/React/Basic/DOM/Components/LogLifecycles.js @@ -0,0 +1,38 @@ +"use strict"; + +var React = require("react"); + +var LogLifecycles = function(_props) { + return this; +}; + +LogLifecycles.prototype = Object.create(React.Component.prototype); + +LogLifecycles.displayName = "LogLifecycles"; + +LogLifecycles.prototype.componentDidMount = function() { + console.info( + this.props.child.type.displayName || this.props.child.type, + "[didMount]" + ); +}; + +LogLifecycles.prototype.componentDidUpdate = function() { + console.info( + this.props.child.type.displayName || this.props.child.type, + "[didUpdate]" + ); +}; + +LogLifecycles.prototype.componentWillUnmount = function() { + console.info( + this.props.child.type.displayName || this.props.child.type, + "[willUnmount]" + ); +}; + +LogLifecycles.prototype.render = function() { + return this.props.child; +}; + +exports.logLifecycles_ = LogLifecycles; diff --git a/src/React/Basic/DOM/Components/LogLifecycles.purs b/src/React/Basic/DOM/Components/LogLifecycles.purs new file mode 100644 index 0000000..57a5fb7 --- /dev/null +++ b/src/React/Basic/DOM/Components/LogLifecycles.purs @@ -0,0 +1,11 @@ +module React.Basic.DOM.Components.LogLifecycles + ( logLifecycles + ) where + +import Prim.TypeError (class Warn, Text) +import React.Basic (JSX, ReactComponent, element) + +foreign import logLifecycles_ :: ReactComponent { child :: JSX } + +logLifecycles :: Warn (Text "LogLifecycle is for debugging purposes only. Don't forget to remove it!") => JSX -> JSX +logLifecycles child = element logLifecycles_ { child } diff --git a/src/React/Basic/DOM/Components/Ref.js b/src/React/Basic/DOM/Components/Ref.js new file mode 100644 index 0000000..5726c4c --- /dev/null +++ b/src/React/Basic/DOM/Components/Ref.js @@ -0,0 +1,43 @@ +"use strict"; + +var React = require("react"); + +exports.mkRef = function(toMaybe) { + var Ref = function(_props) { + this.state = { node: null }; + this.ref = React.createRef(); + return this; + }; + + Ref.prototype = Object.create(React.Component.prototype); + + Ref.displayName = "Ref"; + + Ref.prototype.syncRef = function() { + var selector = this.props.selector; + var current = this.ref.current; + var next = + selector === null ? current : current && current.querySelector(selector); + if (this.state.node !== next) { + this.setState({ node: next }); + } + }; + + Ref.prototype.componentDidMount = function() { + this.syncRef(); + }; + + Ref.prototype.componentDidUpdate = function() { + this.syncRef(); + }; + + Ref.prototype.render = function() { + return React.createElement( + "react-basic-ref", + { ref: this.ref }, + this.props.render(toMaybe(this.state.node)) + ); + }; + + return Ref; +}; diff --git a/src/React/Basic/DOM/Components/Ref.purs b/src/React/Basic/DOM/Components/Ref.purs new file mode 100644 index 0000000..c1b5a5f --- /dev/null +++ b/src/React/Basic/DOM/Components/Ref.purs @@ -0,0 +1,39 @@ +-- | This module provides an efficient (no `ReactDOM.findDOMNode`) and +-- | declarative way to aquire a `Node` for an element in your render +-- | tree. +-- | +-- | For example: +-- | +-- | ```purs +-- | render self = +-- | ref \myRef -> +-- | case myRef of +-- | Nothing -> R.text "First DOM render in progress..." +-- | Just _ -> R.text "First DOM render complete." +-- | ``` +module React.Basic.DOM.Components.Ref + ( ref + , selectorRef + , module Web.DOM.ParentNode + ) where + +import Prelude + +import Data.Maybe (Maybe) +import Data.Nullable (Nullable, notNull, null, toMaybe) +import React.Basic (JSX, ReactComponent, element) +import Web.DOM (Node) +import Web.DOM.ParentNode (QuerySelector(..)) + +foreign import mkRef :: (Nullable ~> Maybe) -> ReactComponent { render :: Maybe Node -> JSX, selector :: Nullable QuerySelector } + +ref_ :: ReactComponent { render :: Maybe Node -> JSX, selector :: Nullable QuerySelector } +ref_ = mkRef toMaybe + +ref :: (Maybe Node -> JSX) -> JSX +ref render = element ref_ { render, selector: null } + +selectorRef :: QuerySelector -> (Maybe Node -> JSX) -> JSX +selectorRef qs render = element ref_ { render, selector: notNull qs } + +-- selectorAllRef :: 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