diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 32906c913..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,91 +0,0 @@ -# Contributing to HyperApp - -HyperApp is an open source project and we love to receive contributions from our community. You can start to contribute in many ways, from writing tutorials or blog posts, improving the documentation, filing bug reports and requesting new features. - -## Quick Start - -Clone the project and install the dependencies. - -```sh -git clone https://github.com/hyperapp/hyperapp -cd hyperapp -npm i -``` - -Run the tests. - -``` -npm run test -``` - -## Filing Bugs - -- Before submitting a bug report, search the issues for similar tickets. Your issue may have already been discussed or resolved. Feel free to add a comment to an existing ticket, even if it's closed. - -- Determine which repository the problem should be reported in. If you have an issue with the website, you'll be better served in [hyperapp/website](https://github.com/hyperapp/website), etc. - -- If you would like to share something cool you've made with HyperApp, check out [hyperapp/awesome](https://github.com/hyperapp/awesome-hyperapp). - -- If you have a question or need help with something you are building, we recommend joining the [HyperApp Slack Team](https://hyperappjs.herokuapp.com). - -- Be thorough in your title and report, don't leave out important details, describe your setup and include any relevant code with your issue. - -- Use GitHub [fenced code blocks](https://help.github.com/articles/creating-and-highlighting-code-blocks/) to share your code. If your code has JSX in it, please use ```jsx for accurate syntax highlighting. - -## Code Style - -- Prefer descriptive single-word variable / function names to single-letter names. - -- Consider improving the [Implementation Notes](/docs/implementation-notes.md) section in the documentation before adding comments to the code. - -- Format your code before adding a commit using [prettier](https://prettier.github.io/prettier) or running the format script. - - ``` - npm run format - ``` - -- With the exception of the ES6 module syntax, HyperApp is written in ES5. - -- We prefer keeping all the moving parts inside as few files as possible. While this may change in the future, we don't intend to break the library into smaller modules. - -## Core Values - -- HyperApp was born out of the attempt to do more with less. - -- HyperApp's design is based on the Elm Architecture and application development is similar to React/Redux using a single immutable state tree. - -- The ideal bundle size is 1 KB, but no more than 1.5 KB. - -## Writing Tests - -- We use [Babel](https://babeljs.io) and [Jest](http://facebook.github.io/jest) to run the tests. - -- Feel free to create a new test/*.test.js file if none of the existing test files suits your test case. - -- Tests usually create an application with [app](/docs/api.md#app) and check if the document.body.innerHTML matches some expected string. The app() call is async, so we sometimes use the [loaded](/docs/api.md#loaded) event to detect when the view has been attached to the document. - -- HyperApp uses [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) under the hood, but it is not natively supported by Jest. For this reason you'll often see the following code at the top of a test file: - - ```js - window.requestAnimationFrame = setTimeout - ``` - -## Code of Conduct - -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. - -Examples of behavior that contributes to creating a positive environment include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at . The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. - -This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org). diff --git a/README.md b/README.md index ffd90487b..61aa3e55a 100644 --- a/README.md +++ b/README.md @@ -6,46 +6,44 @@ HyperApp is a JavaScript library for building frontend applications. -[Elm Architecture]: https://guide.elm-lang.org/architecture/ -[Hyperx]: https://github.com/substack/hyperx -[JSX]: https://facebook.github.io/react/docs/introducing-jsx.html -[CDN]: https://unpkg.com/hyperapp - -* **Minimal**: HyperApp was born out of the attempt to do more with less. We have aggressively minimized the concepts you need to understand while remaining on par with what other frameworks can do. -* **Functional**: HyperApp's design is based on the [Elm Architecture]. Create scalable browser-based applications using a functional paradigm. The twist is you don't have to learn a new language. -* **Batteries-included**: Out of the box, HyperApp combines state management with a Virtual DOM engine that supports keyed updates & lifecycle events — all with no dependencies. +- **Minimal**: HyperApp was born out of the attempt to do [more with less](https://en.wikipedia.org/wiki/Worse_is_better). We have aggressively minimized the concepts you need to understand while remaining on par with what other frameworks can do. +- **Functional**: HyperApp's design is based on [The Elm Architecture](https://guide.elm-lang.org/architecture). Create scalable browser-based applications using a functional paradigm. The twist is you don't have to learn a new language. +- **Batteries-included**: Out of the box, HyperApp combines state management with a Virtual DOM engine that supports keyed updates & lifecycle events — all with no dependencies. [Get started with HyperApp](/docs/getting-started.md) ## Hello World -[Try it online](https://codepen.io/hyperapp/pen/zNxZLP?editors=0010) +[Try it Online](https://codepen.io/hyperapp/pen/zNxZLP?editors=0010) ```jsx app({ - state: 0, + state: { + count: 0 + }, view: (state, actions) =>
-

{state}

- - +

{state.count}

+ +
, actions: { - add: state => state + 1, - sub: state => state - 1 + sub: state => ({ count: state.count - 1 }), + add: state => ({ count: state.count + 1 }) } }) ``` ## Documentation -The documentation is located in the [/docs](/docs) directory. +The documentation is in the [docs](/docs) directory. ## Community -* [Slack](https://hyperappjs.herokuapp.com) -* [/r/hyperapp](https://www.reddit.com/r/hyperapp) -* [Twitter](https://twitter.com/hyperappjs) +- [Slack](https://hyperappjs.herokuapp.com) +- [/r/hyperapp](https://www.reddit.com/r/hyperapp) +- [CodePen](https://codepen.io/hyperapp) +- [Twitter](https://twitter.com/hyperappjs) ## License diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 000000000..b62dbe467 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to HyperApp + +Thank you for taking the time to read our contribution guidelines. You can start to contribute in many ways, from writing tutorials, improving the documentation, filing bug reports and requesting new features. + +## Code of Conduct + +Our open source community strives to: + +- **Be nice.** +- **Be welcoming**: We strive to be a community that welcomes and supports people of all backgrounds and identities. +- **Be considerate**: Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. +- **Be respectful**: Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. +- **Be careful in the words that you choose**: We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. + +This code is not exhaustive or complete. It serves to distill our common understanding of a collaborative, shared environment, and goals. We expect it to be followed in spirit as much as in the letter. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting us at . + +## Code Style + +- Prefer descriptive single-word variable / function names to single-letter names. +- Format your code before committing using [prettier](https://prettier.github.io/prettier) or run the `format` script. +- We use ES6 modules but the rest of the code base is written in ES5. +- We prefer keeping all the moving parts inside as few files as possible. We don't have plans to break up the library into smaller modules. + +## Filing Bugs + +- Before submitting a bug report, search the issues for similar tickets. Your issue may have already been discussed and resolved. Feel free to add a comment to an existing ticket, even if it's closed. +- Determine which repository the problem should be reported in. If you have an issue with the Router, you'll be better served in [hyperapp/router](https://github.com/hyperapp/router), etc. +- If you have a question or need help with something you are building, we recommend joining the [HyperApp Slack Team](https://hyperappjs.herokuapp.com). +- Be thorough in your title and report, don't leave out important details, describe your setup and include any relevant code with your issue. +- Use GitHub [fenced code blocks](https://help.github.com/articles/creating-and-highlighting-code-blocks/) when sharing code. If your code has JSX in it, please use ```jsx for accurate syntax highlighting. + +## Writing Tests + +- We use [Babel](https://babeljs.io) and [Jest](http://facebook.github.io/jest) to run the tests. +- Feel free to create a new `test/*.test.js` file if none of the existing test files suits your test case. +- Tests usually start by creating a small application and using a feature, then check if `document.body.innerHTML` matches some expected string. The app call is async, so we often use [oncreate](/docs/api.md#oncreate) or [onupdate](/docs/api.md#onupdate) lifecycle events to detect when the view has been rendered. +- We use [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) to throttle renders, but it is not natively supported by Jest. For this reason you'll often see the following code at the top of a test file: + + ```js + window.requestAnimationFrame = setTimeout + ``` + diff --git a/docs/README.md b/docs/README.md index 8de6d85ba..27f778cfc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,7 @@ # Documentation -We assume you have some knowledge of HTML and JavaScript. If you are completely new to frontend development, you may prefer to come back after learning the basics. Previous experience with other frameworks is a plus, but not required. - - [Index](/docs/index.md) +- [Contribution Guidelines](/docs/CONTRIBUTING.md) - [Getting Started](/docs/getting-started.md) - [Installation](/docs/getting-started.md#installation) - [Usage](/docs/getting-started.md#usage) @@ -21,6 +20,7 @@ We assume you have some knowledge of HTML and JavaScript. If you are completely - [Keys](/docs/keys.md) - [Components](/docs/components.md) - [Lifecycle Events](/docs/lifecycle-events.md) + - [SSR & Hydration](/docs/hydration.md) - Learning - [Implementation Notes](/docs/implementation-notes.md) - [Tutorials](/docs/tutorials.md) diff --git a/docs/actions.md b/docs/actions.md index ce6e57ca5..030545466 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -1,182 +1,132 @@ # Actions -Use [actions](/docs/api.md#actions) to update the state. -```jsx -app({ - state: "Hi.", - view: (state, actions) => -

- {state} -

, - actions: { - ucase: state => state.toUpperCase() - } -}) -``` +Actions are functions which take the current [state](/docs/state.md) and return a partial state or a [thunk](#thunks). Actioons are the only way to update the state tree. -An action must return a partial state or a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves to a partial state. See [Side Effects](#side-effects). +[Try it Online](https://codepen.io/hyperapp/pen/qRMEGX?editors=0010) ```jsx app({ state: { - count: 0, - maxCount: 10 + text: "Hello!", + defaultText: "Nice weather today." }, - view: (state, actions) => -
+ view: (state, { setText }) => +

- {state.count} + {state.text.trim() === "" + ? state.defaultText + : state.text}

- -
, + setText(e.target.value)} + /> + , actions: { - up: ({ count, maxCount }) => ({ - count: count + (maxCount > count ? 1 : -maxCount) - }) + setText(state, actions, text) { + return { text } + } } }) ``` -You can pass data to actions as well. - -```jsx -app({ - state: { - count: 0 - }, - view: (state, actions) => -
-

- {state.count} -

- -
, - actions: { - up: ({ count }, actions, data = 0) => ({ - count: count + data - }) - } -}) -``` +Actions are often called as a result of user events triggered from the [view](/docs/view.md) or from inside application [events](/docs/events.md). -## Side Effects +## Thunks -Actions are not required to have a return value. You can use them to call other actions, for example after an async operation has completed. +Actions can return a function instead of a partial state. This function is called a _thunk_. They operate like regular actions but will not trigger a state update unless [`update`](/docs/api.md#update) is called from within the thunk function. ```jsx app({ - state: { - count: 0 - }, - view: (state, actions) => -
-

- {state.count} -

- -
, actions: { - up: ({ count }) => ({ - count: count + 1 - }), - upLater: (state, actions) => { - setTimeout(actions.up, 1000) + defer(state, actions, data) { + return update => { + // ... + update(newData) + } } } }) ``` -Actions can return a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). +The action returns the result of the thunk, allowing you to modify how actions operate and what types they can return. + +Use thunks to defer state updates, create [getters](#getters), scoped mixins, etc. + +## Async Updates + +Use [thunks](#thunks) to update the state asynchronously, e.g., after a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) resolves. + +[Try it Online](https://codepen.io/hyperapp/pen/ZeByKv?editors=0010) ```jsx app({ - state: 0, - view: (state, actions) => -
-

- {state} -

- -
, actions: { - upLater: (state, actions) => - new Promise(resolve => setTimeout(resolve, 1000, state + 1)) + getURL(state) { + return update => fetch(`/search?q=${state.query}`) + .then(data => data.json()) + .then(json => update({ + url: json[0].url + }) + ) + } } }) ``` -Actions can be written as [async functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) too. +Actions need not have a return value at all. This way they will not trigger a state update. You can use them to create side effects, call other actions, etc. ```jsx -const delay = result => - new Promise(resolve => setTimeout(resolve, 1000, result)) - app({ - state: 0, - view: (state, actions) => -
-

- {state} -

- -
, actions: { - upLater: async state => await delay(state + 1) + setURL(state, actions, data) { + return { url: data[0].url } + }, + getURL(state, actions) { + const req = new XMLHttpRequest() + + req.open("GET", `/search?q=${state.query}`) + req.onreadystatechange = () => { + if ( + req.readyState === XMLHttpRequest.DONE && + req.status === 200 + ) { + actions.setURL(JSON.parse(req.responseText)) + } + } + req.send() + } } }) ``` -## Namespaces +## Getters -Namespaces let you organize actions into categories or domains. +A getter is an action that retrieves a property from the state tree or the result of a computation. ```jsx app({ - state: 0, - view: (state, actions) => -
- -

- {state} -

- -
, actions: { - counter: { - up: state => state + 1, - down: state => state - 1 + isAdult({ userId }) { + return () => state.users[userId].age >= state.adultAge } } }) ``` -## Complex State - -Suppose we have a complex state object and wish to update a given property avoiding mutation. - -Here is one way we could achieve this using [Ramda](https://github.com/ramda/ramda). +## Namespaces -[Try it online](https://codepen.io/hyperapp/pen/Zygvbg?editors=0010) +We iterate over action keys recursively during setup, allowing for nested actions. ```jsx app({ - state: { - counters: [{ value: 1 }, { value: 2 }, { value: 4 }] - }, actions: { - oneUp: (state, actions, index) => { - return R.over( - R.lensPath(["counters", index, "value"]), - value => value + 1, - state - ) - } + game: gameActions, + score: scoreActions, + ...userActions } }) ``` -See also [lodash/fp](https://github.com/lodash/lodash/wiki/FP-Guide) and [Immutable.js](https://github.com/facebook/immutable-js/) for alternatives. - - - diff --git a/docs/api.md b/docs/api.md index 6cac6ff89..312b9c21e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,21 +1,26 @@ -# API +# Reference - [h](#h) - - [VirtualNode](#virtualnode) - [Component](#component) + - [VirtualNode](#virtualnode) + - [Attributes](#attributes) + - [LifecyleEvents](#lifecyleevents) - [app](#app) - [State](#state) - [View](#view) - [Actions](#actions) + - [ActionInfo](#actioninfo) - [ActionResult](#actionresult) + - [Thunk](#thunk) - [Events](#events) - [Default Events](#default-events) - - [ActionData](#actiondata) - [CustomEvent](#customevent) - [Mixins](#mixins) - [Mixin](#mixin) -- [emit](#emit) + - [Root](#root) +- [Emit](#emit) +- [Update](#update) @@ -24,32 +29,48 @@
 h(
   string | Component,
-  object,
+  Attributes,
+  Array<VirtualNode> | string
+): VirtualNode
+
+ +### Component + +See [Components](/docs/components.md). + +
+Component(
+  any,
   Array<VirtualNode> | string
 ): VirtualNode
 
### VirtualNode -See also [Virtual Nodes](/docs/virtual-nodes.md). +See [Virtual Nodes](/docs/virtual-nodes.md).
 {
   tag: string,
-  data: object,
-  children: Array<VirtualNode>
+  data: Attributes,
+  children: Array<VirtualNode>
 }
 
-### Component +### Attributes -See also [Components](/docs/components.md). +
+HTMLAttributes | SVGAttributes | DOMEvents | LifecyleEvents
+
+ +#### LifecyleEvents + +See [Lifecyle Events](/docs/lifecyle-events.md).
-Component(
-  any,
-  Array<VirtualNode> | string
-): VirtualNode
+oncreate(Element): void
+onupdate(Element, Attributes): void
+onremove(Element): void
 
## app @@ -61,21 +82,25 @@ app({ actions: Actions, events: Events, mixins: Mixins, - root: Element = document.body -}): emit + root: Root +}): Emit ### State -See also [State](/docs/state.md). +See [State](/docs/state.md).
-string | number | boolean | object
+{
+  [key: string]:
+    | PartialState
+    | any
+}
 
### View -See also [View](/docs/view.md). +See [View](/docs/view.md).
 (State, Actions): VirtualNode
@@ -83,7 +108,7 @@ See also [View](/docs/view.md).
 
 ### Actions
 
-See also [Actions](/docs/actions.md).
+See [Actions](/docs/actions.md).
 
 
 {
@@ -93,37 +118,47 @@ See also [Actions](/docs/actions.md).
 }
 
+#### ActionInfo + +
+{
+  name: string,
+  data: any
+}
+
+ #### ActionResult -A partial state or [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves to a partial state. +A partial [State](#state) or [Thunk](#thunk). + +#### Thunk + +See [Thunks](/docs/actions.md#thunks). + +
+(Update): any
+
### Events -See also [Events](/docs/events.md). +See [Events](/docs/events.md).
 {
-  [event: string]: Array<CustomEvent> | CustomEvent
+  [event: string]:
+    | Array<CustomEvent>
+    | CustomEvent
 }
 
#### Default Events
-init(State, Actions): void
-loaded(State, Actions): void
-action(State, Actions, ActionData): ActionData
-update(State, Actions, ActionResult): ActionResult
+load(State, Actions, Root): VirtualNode
 render(State, Actions, View): View
-
- -##### ActionData - -
-{
-  action: string,
-  data: any
-}
+action(State, Actions, ActionInfo): ActionInfo
+resolve(State, Actions, ActionResult): ActionResult
+update(State, Actions, nextState): State
 
#### CustomEvent @@ -134,7 +169,7 @@ See also [Events](/docs/events.md). ### Mixins -See also [Mixins](/docs/mixins.md). +See [Mixins](/docs/mixins.md).
 Array<Mixin>
@@ -143,7 +178,7 @@ Array<Mixin>
 #### Mixin
 
 
-(emit): {
+(Emit): {
   state: State,
   actions: Actions,
   events: Events,
@@ -151,12 +186,28 @@ Array<Mixin>
 }
 
-## emit +### Root + +
+Element
+
+ +See [Root](/docs/root.md). + +## Emit + +See [Custom Events](/docs/events.md#custom-events). + +
+(string, any): any
+
+ +## Update -See also [Custom Events](/docs/events.md#custom-events). +See [Thunks](/docs/actions.md#thunks).
-emit(string, any): any
+(PartialState): void
 
diff --git a/docs/components.md b/docs/components.md index d3ea6ad2b..85ef5fb33 100644 --- a/docs/components.md +++ b/docs/components.md @@ -2,7 +2,7 @@ A [component](/docs/api.md#component) is a function that returns a custom [virtual node](/docs/virtual-nodes.md). Components are reusable blocks of code that encapsulate markup, styles and behaviours that belong together. -[Try it online](https://codepen.io/hyperapp/pen/WRWbKw?editors=0010) +[Try it Online](https://codepen.io/hyperapp/pen/WRWbKw?editors=0010) ```js const Title = ({ url, value }/*, children*/) => @@ -13,7 +13,7 @@ const Title = ({ url, value }/*, children*/) => app({ view: () =>
- + <Title url="#" value="Jump" /> </main> }) ``` @@ -27,14 +27,14 @@ Here is the corresponding virtual node. id: "app" }, children: [{ - tag: "a", - data: { - href: "#" - }, + tag: "h1", + data: {}, children: [{ - tag: "h1", - data: undefined, - children: ["Hello."] + tag: "a", + data: { + href: "#" + }, + children: ["Jump"] }] }] } @@ -51,5 +51,5 @@ const Link = (props, children) => ## Component Lifecycle Events -Components share the same lifecycle events available to virtual nodes. See [Lifecyle Events](/docs/lifecycle-events.md) for details. +Components share the same lifecycle events as virtual nodes. See [Lifecyle Events](/docs/lifecycle-events.md) for more information. diff --git a/docs/countdown-timer.md b/docs/countdown-timer.md index ede95f209..2d084745a 100644 --- a/docs/countdown-timer.md +++ b/docs/countdown-timer.md @@ -2,7 +2,7 @@ In this example and learn how to use the [events](/docs/events.md) property to register global events. -[Try it online](https://codepen.io/hyperapp/pen/evOZLv?editors=0010) +[Try it Online](https://codepen.io/hyperapp/pen/evOZLv?editors=0010) ```jsx const pad = n => (n < 10 ? "0" + n : n) @@ -47,7 +47,9 @@ app({ } }, events: { - init: (state, actions) => setInterval(actions.tick, 1000) + load(state, actions) { + setInterval(actions.tick, 1000) + } } }) ``` @@ -75,7 +77,9 @@ To simulate the clock we use [`setInterval`](https://developer.mozilla.org/en-US ```jsx events: { - init: (state, actions) => setInterval(actions.tick, 1000) + load(state, actions){ + setInterval(actions.tick, 1000) + } } ``` @@ -91,7 +95,3 @@ if (state.count === 0) { actions.drop() } ``` - -<br /> - -[Back to Tutorials](/docs/tutorials.md) diff --git a/docs/counter.md b/docs/counter.md index e1a198a9c..688c489e5 100644 --- a/docs/counter.md +++ b/docs/counter.md @@ -2,60 +2,60 @@ In this example we'll learn how to use [actions](/docs/actions.md) to update the state of your application. -[Try it online](https://codepen.io/hyperapp/pen/zNxZLP?editors=0010) +[Try it Online](https://codepen.io/hyperapp/pen/zNxZLP?editors=0010) ```jsx app({ - state: 0, + state: { + count: 0 + }, view: (state, actions) => <main> <h1> - {state} + {state.count} </h1> + <button onclick={actions.sub} disabled={state.count <= 0}>ー</button> <button onclick={actions.add}>+</button> - <button onclick={actions.sub} disabled={state <= 0}>ー</button> </main>, actions: { - add: state => state + 1, - sub: state => state - 1 + sub: state => ({ count: state.count - 1 }), + add: state => ({ count: state.count + 1 }) } }) ``` -The state is a number and its initial value is 0. +The state consists of a single property: `count` which is initialized to 0. ```jsx -state: 0 +state: { + count: 0 +} ``` The view function receives the state as the first argument and uses it to display the current value of the counter inside an `<h1>` tag. ```jsx -<h1>{state}</h1> +<h1>{state.count}</h1> ``` The view also defines two buttons with `onclick` handlers attached to them. The handlers are available in the actions object that is passed to the view as the second argument. ```jsx +<button onclick={actions.sub} disabled={state.count <= 0}>-</button> <button onclick={actions.add}>+</button> -<button onclick={actions.sub} disabled={state <= 0}>-</button> ``` The `disabled` attribute is dynamically toggled depending on the value of the counter. This prevents the decrement button from being clicked when the counter reaches zero. ```jsx -disabled={state <= 0} +disabled={state.count <= 0} ``` -Note that neither of the actions update the state directly, instead, they return a new state. +Note that neither of the actions update the state directly, instead, they return a partial state. ```jsx -add: state => state + 1, -sub: state => state - 1 +sub: state => ({ count: state.count - 1 }), +add: state => ({ count: state.count + 1 }) ``` When the state is updated as a result of calling an action, the view function is called and the application is rendered again. - -<br/> - -[Back to Tutorials](/docs/tutorials.md) diff --git a/docs/events.md b/docs/events.md index 4798e9a9a..29122cc33 100644 --- a/docs/events.md +++ b/docs/events.md @@ -1,8 +1,16 @@ # Events -Use events to get notified when your app is initialized, an action is called, before a [view](/docs/view.md) is rendered, etc. +Events are function called at various points in the life of your application. -[Try it online](https://codepen.io/hyperapp/pen/Bpyraw?editors=0010) +Use events to get notified before the [state](/docs/state.md) is updated, your [view](/docs/view.md) is rendered, an [action](/docs/actions.md) is called, etc. + +## Default Events + +### load + +The [`load`](/docs/api.md#load) event is fired before the first render. Use it to initialize your application, listen to global events, create a network request, etc. + +[Try it Online](https://codepen.io/hyperapp/pen/Bpyraw?editors=0010) ```jsx app({ @@ -12,51 +20,91 @@ app({ move: (state, actions, { x, y }) => ({ x, y }) }, events: { - init: (state, actions) => + load(state, actions) { addEventListener("mousemove", e => actions.move({ x: e.clientX, y: e.clientY }) ) + } } }) ``` -## Default Events - -### init +### action -The init event fires before the first render. This is a good place to initialize your application, create a network request, access the local [Storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage), etc. +The [`action`](/docs/api.md#action) event is fired before an action is called. Use it to log action activity, extract information about actions, etc. -### loaded +```jsx +app({ + events: { + action(state, actions, { name, data }) { + console.group("Action Info") + console.log("Name:", name) + console.log("Data:", data) + console.groupEnd() + } + } +}) +``` -The loaded event fires after the first render. This event is useful if you need to access actual DOM nodes after initialization. +### resolve -### beforeAction +The [`resolve`](/docs/api.md#resolve) event is fired after an action is called, allowing you to intercept its return value. Use it to customize the types actions can return. -The beforeAction event fires before an action is called. This event can be useful to implement middleware, developer tools, etc. +Allow actions to return a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). -### afterAction +```jsx +app({ + events: { + resolve(state, actions, result) { + if (result && typeof result.then === "function") { + return result.then(update) && result + } + } + } +}) + ``` +Allow actions to return an [Observable](https://github.com/tc39/proposal-observable). -The afterAction event fires after an action is called. This event can be useful to implement middleware, developer tools, etc. +```jsx +app({ + events: { + resolve(state, actions, result) { + if (result && typeof result.subscribe == "function") { + return update => result.subscribe({ next: update }) + } + } + } +}) +``` ### update -The update event fires before the state is updated. This event can be useful to validate the state before an update takes place. +The [`update`](/docs/api.md#eventsupdate) event is fired before the state is updated. Use this event to log state changes, validate the new state before an update takes place, etc. + +```jsx +app({ + events: { + update(state, actions, nextState) { + if (validate(nextState)) { + return nextState + } + } + } +}) +``` ### render -The render event fires every time before the view is rendered. You can use this event to overwrite the current view by returning a new one. +The [`render`](/docs/api.md#render) event is fired before the [view](/docs/view.md) function is called, allowing you to overwrite it or decorate it. If your application does not use a view, this event is never fired. ```jsx app({ - view: state => <h1>Hi.</h1>, events: { - render(state, actions) { - if (location.pathname === "/warp") { - return state => <h1>Welcome to warp zone!</h1> - } + render(state, actions, view) { + return location.pathname === "/" ? defaultView : notFoundView } } }) @@ -64,19 +112,20 @@ app({ ## Custom Events -Create custom events using the [`emit`](/docs/api.md#emit) function. +Create custom events with the [`emit`](/docs/api.md#emit) function. ```jsx emit("myEvent", data) ``` -Then subscribe to them in your application or [mixin](/docs/mixins.md). +Then subscribe to them like any other event. ```jsx app({ events: { myEvent(state, actions, data) { - // return new data + // ... + return newData } } }) @@ -85,34 +134,37 @@ app({ The `emit` function is available as the return value of the [`app`](/docs/api.md#app) function call itself. ```js -const emit = app({ ... }) +const emit = app({ + // ... +}) ``` Or in mixins, as the first argument to the function. ```js -const MyMixin = emit => ({ ... }) +function MyMixin(emit) { + // ... +} ``` -The `emit` function returns the supplied data reduced by successively calling each event handler of the specified event. +The `emit` function returns the supplied data, reduced by successively calling each event handler of the specified event. -### Interoperatiblity +### Interoperability Custom events can be useful in situations where your application is a part of a larger system and you want to communicate with it from the outside. ```js const emit = app({ - ... events: { - externalEvent: (state, actions, data) => actions.setData(data) + externalEvent(state, actions, data) { + actions.populate(data) + } } }) -... +// ... -emit("externalEvent", { - data: 42 -}) +emit("externalEvent", yourData) ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 62d1a0731..fc8a17fca 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,8 +12,10 @@ Let's begin with the simplest of all programs. Paste the following code in a new const { h, app } = hyperapp app({ - state: "Hello.", - view: state => h("h1", {}, state) + state: { + message: "Hello." + }, + view: state => h("h1", {}, state.message) }) </script> @@ -22,31 +24,31 @@ app({ You should see that "Hello." is displayed on the page. +### Dissecting the Code + The state describes the application's data. ```js -state: "Hello." +state: { + message: "Hello." +} ``` The view describes the application's user interface. ```js -state => h("h1", {}, state) +state => h("h1", {}, state.message) ``` -You can write a view using [JSX] or [Hyperx] and compile it in a [build pipeline](#build-pipeline). +You can write a view using [JSX], [hyperx], etc., and compile it in a [build pipeline](#build-pipeline). ```jsx -state => <h1>{state}</h1> +state => <h1>{state.message}</h1> ``` -The [app](/docs/api.md#app) function wraps up everything and renders the view on the DOM. - -And... we're done. +The [app](/docs/api.md#app) function wraps it all together and renders the view on the DOM. ---- - -We've only scratched the surface of what you can do and what's available in HyperApp. To learn more, check out the [Tutorials](/docs/tutorials.md) or read the [Implementation Notes](/docs/implementation-nodes.md) to peek under the hood. +To learn more, check out the [Tutorials](/docs/tutorials.md) or read the [Implementation Notes](/docs/implementation-nodes.md) to peek under the hood. ## Installation @@ -78,24 +80,24 @@ import { h, app } from "hyperapp" A build pipeline typically consists of a package manager, a compiler and a bundler. -Using a build pipeline we can transform JSX / Hyperx markup into [h](/docs/api.md#h) calls before runtime. This is much faster than sending a parser down the wire and compiling the view in the browser. +Using a build pipeline we can transform JSX or hyperx markup into [`h`](/docs/api.md#h) calls before runtime. This is much faster than sending a parser down the wire and compiling the view in the browser. -JSX / Hyperx in: +JSX or hyperx ```jsx <main id="app">Hello.</main> ``` -Vanilla out: +Vanilla out ```jsx h("main", { id: "app" }, "Hello.") ``` -A build pipeline lets you easily install and update third-party libraries, compile modern JavaScript for older browser and bundle your application into small modules to optimize load time. +A build pipeline lets you install and update third-party libraries easily, compile modern JavaScript for older browser and bundle your application into small modules to optimize load time. -See [JSX] or [Hyperx] for setup instructions. +See [JSX] or [hyperx] for setup instructions. -[Hyperx]: /docs/hyperx.md +[hyperx]: /docs/hyperx.md [JSX]: /docs/jsx.md - +[t7]: https://github.com/trueadm/t7 diff --git a/docs/gif-search.md b/docs/gif-search.md index b4b44cbc1..9f6f29db7 100644 --- a/docs/gif-search.md +++ b/docs/gif-search.md @@ -2,7 +2,7 @@ In this example we'll use the [Giphy API](https://api.giphy.com/) to create a GIF search and learn how to update the state asynchronously. -[Try it online](https://codepen.io/hyperapp/pen/ZeByKv?editors=0010) +[Try it Online](https://codepen.io/hyperapp/pen/ZeByKv?editors=0010) ```jsx app({ @@ -90,7 +90,3 @@ fetch( ``` Finally, call `actions.toggleFetching` to allow further fetch requests to be made and update the state by passing the fetched GIF URL to `actions.setUrl`. - -<br /> - -[Back to Tutorials](/docs/tutorials.md) diff --git a/docs/hyperx.md b/docs/hyperx.md index c0b69e984..43e740eee 100644 --- a/docs/hyperx.md +++ b/docs/hyperx.md @@ -15,11 +15,11 @@ const main = html` ## Setup -We can use [Hyperxify](https://github.com/substack/hyperxify) to transform Hyperx into [`h`](/docs/h.md#h) function calls and a bundler to create a single file we can deliver to the browser. +We can use [hyperxify](https://github.com/substack/hyperxify) to transform hyperx into [`h`](/docs/h.md#h) function calls and a bundler to create a single file we can deliver to the browser. -The ES6 import syntax is incompatible with Hyperxify, so we'll use the Node.js require function. +The ES6 import syntax is incompatible with hyperxify, so we'll use the Node.js require function. -In a new directory, create an `index.html` file: +In a new directory, create an `index.html` file. ```html <!doctype html> @@ -32,7 +32,7 @@ In a new directory, create an `index.html` file: </html> ``` -And and `index.js` file: +And an `index.js` file. ```js const { h, app } = require("hyperapp") @@ -40,19 +40,21 @@ const hyperx = require("hyperx") const html = hyperx(h) app({ - state: "Hi.", - view: state => html`<h1>${state}</h1>` + state: { + message: "Hi." + }, + view: state => html`<h1>${state.message}</h1>` }) ``` -Install dependencies: +Install dependencies. <pre> npm i <a href="https://www.npmjs.com/package/hyperapp">hyperapp</a> </pre> ## [Browserify](https://gist.github.com/jbucaran/48c1edb4fb0ea1aa5415b6686cc7fb45 "Get this gist") -Install development dependencies: +Install development dependencies. <pre> npm i -D \ <a href="https://www.npmjs.com/package/browserify">browserify</a> \ @@ -64,7 +66,7 @@ npm i -D \ <a href="https://www.npmjs.com/package/uglify-js">uglify-js</a> </pre> -Create a `.babelrc` file: +Create a `.babelrc` file. ``` { @@ -72,7 +74,7 @@ Create a `.babelrc` file: } ``` -Bundle the application: +Bundle the application. <pre> $(<a href="https://docs.npmjs.com/cli/bin">npm bin</a>)/browserify \ -t hyperxify \ @@ -83,7 +85,7 @@ $(<a href="https://docs.npmjs.com/cli/bin">npm bin</a>)/browserify \ ## [Webpack](https://gist.github.com/jbucaran/c6a6bdb5383a985cec6b0ae4ebe5a4b1 "Get this gist") -Install development dependencies: +Install development dependencies. <pre> npm i -D \ <a href="https://www.npmjs.com/package/hyperx">hyperx</a> \ @@ -95,7 +97,7 @@ npm i -D \ <a href="https://www.npmjs.com/package/babel-preset-es2015">babel-preset-es2015</a> </pre> -Create a `.babelrc` file: +Create a `.babelrc` file. ```js { "presets": ["es2015"] @@ -122,7 +124,7 @@ module.exports = { } ``` -Bundle the application: +Bundle the application. <pre> $(<a href="https://docs.npmjs.com/cli/bin">npm bin</a>)/webpack -p </pre> diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md index 49b0c71ea..c7e664b0c 100644 --- a/docs/implementation-notes.md +++ b/docs/implementation-notes.md @@ -7,3 +7,22 @@ - render scheduler - recycling - hydration + + +patch + createElement + setElementData + updateElement + setElementData + removeElement + + +update + +Create map with old keyed nodes. +Update the element's children. +Remove remaining unkeyed old nodes. +Remove unused keyed old nodes. + + + diff --git a/docs/index.md b/docs/index.md index 58a619692..aa5341d8a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,8 @@ Below is an alphabetical list of some of the terms and concepts used throughout ##### A [app](/docs/api.md#app)<br> [actions](/docs/actions.md)<br> -[events.action](/docs/api.md#action)<br> -[attributes](/docs/virtual-nodes.md#data-attributes)<br> +[events.action](/docs/events.md#action)<br> +[attributes](/docs/virtual-nodes.md#attributes)<br> ##### B [build pipeline](/docs/getting-started.md#build-pipeline)<br> @@ -20,17 +20,11 @@ Below is an alphabetical list of some of the terms and concepts used throughout [events, custom](/docs/events.md#custom-events)<br> [events, lifecycle](/docs/lifecycle-events.md)<br> -##### F -[fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)<br> - ##### H [h](/docs/api.md#h)<br> [hyperx](/docs/hyperx.md)<br> [hyperxify](https://github.com/substack/hyperxify)<br> -##### I -[events.init](/docs/api.md#init)<br> - ##### J [JSX](/docs/jsx.md)<br> @@ -38,7 +32,7 @@ Below is an alphabetical list of some of the terms and concepts used throughout [keys](/docs/keys.md)<br> ##### L -[events.loaded](/docs/api.md#loaded)<br> +[events.load](/docs/api.md#load)<br> ##### M [mixins](/docs/mixins.md)<br> @@ -48,22 +42,22 @@ Below is an alphabetical list of some of the terms and concepts used throughout ##### O [oncreate](/docs/lifecycle-events.md#oncreate)<br> -[oninsert](/docs/lifecycle-events.md#onisert)<br> [onupdate](/docs/lifecycle-events.md#onupdate)<br> [onremove](/docs/lifecycle-events.md#onremove)<br> ##### R -[root](/docs/api.md#root)<br> -[events.render](/docs/api.md#render)<br> +[root](/docs/root.md)<br> +[events.resolve](/docs/events.md#resolve)<br> +[events.render](/docs/events.md#render)<br> ##### S [state](/docs/state.md)<br> ##### T -[tutorials](/docs/tutorials.md)<br> +[thunks](/docs/actions.md#thunks) ##### U -[events.update](/docs/api.md#update)<br> +[events.update](/docs/events.md#eventsupdate)<br> ##### V [view](/docs/view.md)<br> diff --git a/docs/jsx.md b/docs/jsx.md index 84ada1cdd..63579a7c5 100644 --- a/docs/jsx.md +++ b/docs/jsx.md @@ -2,7 +2,7 @@ [JSX](https://facebook.github.io/jsx/) is an XML-like syntax extension to ECMAScript. It allows you to mix HTML and JavaScript. -JSX is not part of the ECMAScript standard, but using the appropriate tooling we can compile our JavaScript/JSX code into JavaScript browsers understand. +JSX is not part of the ECMAScript standard, but using the appropriate tooling we can compile JSX code into JavaScript browsers understand. ```jsx <div> @@ -17,7 +17,7 @@ For an in-depth introduction to JSX, see the official [documentation](https://fa We can use [Babel](https://github.com/babel/babel) to transform JSX into [`h`](/docs/api.md#h) function calls and a bundler to create a single file we can deliver to the browser. -In a new directory, create an `index.html` file: +In a new directory, create an `index.html` file. ```html <!doctype html> @@ -36,19 +36,21 @@ And an `index.js` file: import { h, app } from "hyperapp" app({ - state: "Hi.", - view: state => <h1>{state}</h1> + state: { + message: "Hi." + }, + view: state => <h1>{state.message}</h1> }) ``` -Install dependencies: +Install dependencies. <pre> npm i <a href="https://www.npmjs.com/package/hyperapp">hyperapp</a> </pre> ## [Browserify](https://gist.github.com/jbucaran/21bbf0bbb0fe97345505664883100706 "Get this gist") -Install development dependencies: +Install development dependencies. <pre> npm i -D \ <a href="https://www.npmjs.com/package/babel-plugin-transform-react-jsx">babel-plugin-transform-react-jsx</a> \ @@ -76,7 +78,7 @@ Create a `.babelrc` file: } ``` -Bundle the application: +Bundle the application. <pre> $(<a href="https://docs.npmjs.com/cli/bin">npm bin</a>)/browserify \ -t babelify \ @@ -86,7 +88,7 @@ $(<a href="https://docs.npmjs.com/cli/bin">npm bin</a>)/browserify \ ## [Webpack](https://gist.github.com/jbucaran/6010a83891043a6e0c37a3cec684c08e "Get this gist") -Install development dependencies: +Install development dependencies. <pre> npm i -D \ <a href="https://www.npmjs.com/package/webpack">webpack</a> \ @@ -96,7 +98,7 @@ npm i -D \ <a href="https://www.npmjs.com/package/babel-plugin-transform-react-jsx">babel-plugin-transform-react-jsx</a> </pre> -Create a `.babelrc` file: +Create a `.babelrc` file. ```js { "presets": ["es2015"], @@ -129,14 +131,14 @@ module.exports = { } ``` -Bundle the application: +Bundle the application. <pre> $(<a href="https://docs.npmjs.com/cli/bin">npm bin</a>)/webpack -p </pre> ## [Rollup](https://gist.github.com/jbucaran/0c0da8f1256a0a66090151cfda777c2c "Get this gist") -Install development dependencies: +Install development dependencies. <pre> npm i -D \ <a href="https://www.npmjs.com/package/rollup">rollup</a> \ @@ -148,7 +150,7 @@ npm i -D \ </pre> -Create a `rollup.config.js` file: +Create a `rollup.config.js` file. ```jsx import babel from "rollup-plugin-babel" @@ -172,7 +174,7 @@ export default { } ``` -Bundle the application: +Bundle the application. <pre> $(<a href="https://docs.npmjs.com/cli/bin">npm bin</a>)/rollup -cf iife -i index.js -o bundle.js </pre> diff --git a/docs/keys.md b/docs/keys.md index be6bea136..059be7819 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -2,34 +2,21 @@ Every time your application is rendered, a virtual node tree is created from scratch. -Keys help identify which nodes were added, changed or removed from the old / new tree. +Keys help identify which nodes were added, changed or removed from the old and new tree. -Use keys to tell the render algorithm to re-order the children instead of mutating them. +Use keys to tell the patch algorithm to re-order children instead of mutate them. ```jsx <ul> - {urls.map((url, id) => ( + {urls.map((url, id) => <li key={id}> <img src={url} /> </li> - ))} + )} </ul> ``` -Use keys also to force an element to be created only once. - -```jsx -<ul> - <li key="hyper">Hyper</li> - <li>Super</li> - <li>Ultra</li> -</ul> -``` - -If new elements are added to the list, the position of the keyed element will change. - -Using a key in this way, we make sure the keyed element is always inserted in the right position instead of mutating its siblings to obtain the same result. - ## Caution Keys are not registered on the top-level node of your [view](/docs/view.md). If you are switching the top level view, and you need to use keys, wrap them in an unchanging node. + diff --git a/docs/lifecycle-events.md b/docs/lifecycle-events.md index eb2461841..f5986f53d 100644 --- a/docs/lifecycle-events.md +++ b/docs/lifecycle-events.md @@ -1,61 +1,95 @@ # Lifecycle Events -Lifecycle events are custom function handlers invoked at various points in the life of a [virtual node](/docs/virtual-nodes.md). - -<pre> -<i>event</i>(<a href="https://developer.mozilla.org/en-US/docs/Web/API/Element">Element</a>) -</pre> +Lifecycle events are functions called at various points in the life of a [virtual node](/docs/virtual-nodes.md). They are used like any other [attribute](/docs/virtual-nodes.md#attributes). ## oncreate -The oncreate event is fired when the element is created, but before it is inserted into the DOM. Use this event to start animations before an element is rendered. - -## oninsert +The [`oncreate`](/docs/api.md#oncreate) event is fired after the element is created and attached to the DOM. Use it to manipulate the DOM node directly, create animations etc. -The oninsert event is fired after the element is created and inserted into the DOM. Use this event to wrap third party libraries that require a reference to a DOM node, etc. +```jsx +app({ + view: () => + <input + type="text" + oncreate={element => { + element.focus() + }} + /> +}) +``` ## onupdate -The onupdate event is fired every time the element's data is updated. - -## onremove - -The onremove event is fired before the element is removed from the DOM. - -When using `onremove`, you will most likely need the [node](/docs/virtual-nodes.md) to also be [keyed](/docs/keys.md). If not, the elements removed are not guaranteed to correspond to any particular node. As a consequence, `onremove` may not work for the topmost element of your [view](/docs/view.md). - -You are responsible for removing the element if you use this event. +The [`onupdate`](/docs/api.md#onupdate) event is fired after the element attributes are updated. This event will fire even if the attributes have not changed. You can use `oldProps` inside the function to check if they changed or not. -```js -if (element.parentNode) { - element.parentNode.removeChild(element) -} +```jsx +app({ + view: state => + <main> + <MyComponent + value={state.value} + onupdate={(element, oldProps) => { + if (state.value !== oldProps.value) { + // ... + } + }} + /> + </main> +}) ``` -## CodeMirror Example - -This example shows how to create a [component](/docs/components.md) and wrap a subset of the [CodeMirror](https://codemirror.net) editor. +## onremove -[Try it online](https://hyperapp-code-mirror.glitch.me) +The [`onremove`](/docs/api.md#onremove) event is fired before the element is removed from the DOM. Use it for cleaning up resources like timers, creating slide/fade out animations, etc. ```jsx -const node = document.createElement("div") -const editor = CodeMirror(node) - -const Editor = props => { - const setOptions = props => - Object.keys(props).forEach(key => - editor.setOption(key, props[key])) - - return ( +app({ + view: () => <div - oninsert={element => { - setOptions(props) - element.appendChild(node) + onremove={element => { + element.classList.add("slide-out") + setTimeout(() => { + if (element.parentNode) { + element.parentNode.removeChild(element) + } + }, 1000) }} - onupdate={() => setOptions(props)} /> - ) -} +}) ``` +Notice that when using this event you must also remove the element. + +## Adapting an External Library + +This example shows how to integrate the [CodeMirror](https://codemirror.net/) editor in an application. + +[Try it Online](https://hyperapp-code-mirror.glitch.me) + +```jsx +const Editor = props => + <div + oncreate={element => { + const cm = CodeMirror(node => element.appendChild(node)) + + // Share it. + element.CodeMirror = { + set: props => + Object.keys(props).forEach(key => cm.setOption(key, props[key])) + } + + element.CodeMirror.set(props) + }} + onupdate={element => element.CodeMirror.set(props)} + /> + +app({ + //... + view: state => + <Editor + mode={state.mode} + theme={state.theme} + lineNumbers={state.lineNumbers} + /> +}) +``` \ No newline at end of file diff --git a/docs/mixins.md b/docs/mixins.md index 657f478a9..b8ae1ded1 100644 --- a/docs/mixins.md +++ b/docs/mixins.md @@ -2,48 +2,32 @@ Use [mixins](/docs/api.md#mixins) to encapsulate your application behavior into reusable modules, to share or just to organize your code. -```jsx -const ActionsLogger = () => ({ - events: { - beforeAction: (state, actions, { name, data }) => { - console.group("Action Info") - console.log("Name:", name) - console.log("Data:", data) - console.groupEnd() - } - } -}) -``` - -This mixin listens to [beforeAction](/docs/events.md#beforeAction) events to log action information to the console. - ```jsx app({ - // Your app! - ..., - mixins: [ActionsLogger] + //..., + mixins: [MyMixin] }) ``` ## Emitting Events -### Example #1 +Mixins receive the [`emit`](/docs/api.md#emit) function as the first argument allowing you to create [custom events](/docs/events.md#custom-events). -Mixins receive the [`emit`](/docs/api.md#emit) function as the first argument. Use it to create new events, etc. +This mixin listens to [action](/docs/events.md#action) and [resolve](/docs/events.md#resolve) events to time the duration between each action. ```jsx -const ActionPerformance = (ignore = [], cache = []) => emit => ({ +const ActionPerformanceTimer = (ignored = [], cache = []) => emit => ({ events: { - beforeAction(state, actions, { name }) { + action(state, actions, { name }) { cache.push({ name, time: performance.now() }) }, - afterAction() { + resolve() { const { name, time } = cache.pop() - if (ignore.length === 0 || !ignore.includes(name)) { + if (!ignored.includes(name)) { emit("time", { name, time: performance.now() - time @@ -56,55 +40,24 @@ const ActionPerformance = (ignore = [], cache = []) => emit => ({ ```jsx app({ - // Your app! - ..., events: { - time: (state, actions, { name, time }) => { + time(state, actions, { name, time }) { console.group("Action Time Info") console.log("Name:", name) console.log("Time:", time) console.groupEnd() } }, - mixins: [ActionPerformance()] -}) -``` - -### Example #2 - -This mixin emits a `hash` event every time a fragment identifier of the URL changes allowing the user to validate the `location.hash`. - -```jsx -const HashGuard = emit => ({ - events: { - loaded() { - addEventListener("hashchange", () => { - const validHash = emit("hash", location.hash) - - if (location.hash !== validHash) { - location.hash = validHash - } - }) - } - } -}) -``` - -```jsx -app({ - events: { - hash: (state, actions, hash) => validateHash(hash) - }, - mixins: [HashGuard] + mixins: [ActionPerformanceTimer()] }) ``` ## Presets -Mixins can be used to create presets of other mixins. Then use it like any +Group mixins under the same category to create a preset. Then use it like any other mixin. ```jsx -const MyPreset = () => ({ - mixins: [MyMixin1, MyMixin2] +const DevTools = () => ({ + mixins: [Logger, Debugger, Replayer] }) ``` diff --git a/docs/root.md b/docs/root.md index 036b6ffeb..4a8f297c7 100644 --- a/docs/root.md +++ b/docs/root.md @@ -1,12 +1,42 @@ # Root -The view is attached to the [document.body](https://developer.mozilla.org/en-US/docs/Web/API/Document/body) by default. +The `root` represents the top-level element in your application. If one isn't supplied, the [view](/docs/view.md) is rendered in a new element and appended to the [document.body](https://developer.mozilla.org/en-US/docs/Web/API/Document/body). -To mount the application on a different element, use the root property. +```jsx +app({ + view: () => <h1>Hi.</h1> +}) +``` + +This is the result. + +```html +<body> + <h1>Hi.</h1> <!-- The root. --> +</body> +``` + +Use the `root` property to target an arbitrary HTML element, overwriting any existing elements inside it. ```jsx app({ - view: () => <h1>Hi.</h1>, - root: document.getElementById("app") + root: document.getElementById("app"), + view: () => <h1>Hi.</h1> }) ``` + +This is the original HTML. + +```html +<body> + <div id="app">Hello.</div> +</body> +``` + +This is the result after the view has been rendered. + +```html +<body> + <h1>Hi.</h1> +</body> +``` diff --git a/docs/state.md b/docs/state.md index 5b2525fc4..0432f0e6e 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,26 +1,27 @@ # State -Use the [state](/docs/api.md#state) to model your application's data. +The [state](/docs/api.md#state) represents the entire data model in your application. + +[Try it Online](https://codepen.io/hyperapp/pen/zNxRLy?editors=0110) ```jsx app({ state: { - name: "Optimus", - age: 5000000 + todos: [ + { + id: 1337, + done: false, + text: "Empty trash." + } + ], + todoText: "" }, view: state => <main> - <div>Name: {state.name}</div> - <div>Age: {state.age}</div> + ... + {state.todos.map(todo => <TodoItem {...todo} />)} </main> }) ``` -The state property is usually an object, but it can also be a string, number or a boolean. - -```jsx -app({ - state: "Bang!", - view: state => <h1>{state}</h1> -}) -``` +The notion of representing the application state as a single source of truth is known as single state tree. The tree is populated using a concept called [actions](/docs/actions.md). diff --git a/docs/tutorials.md b/docs/tutorials.md index 4d7f01a51..d3e46cf43 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -2,7 +2,7 @@ In this page you can browse our catalog of tutorials. Tutorials provide narrative, step-by-step instruction of a variety of topics and scenarios that teach you how to use HyperApp. -Many of the examples make use of new language features in JavaScript such as: [arrow functions](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Functions/Arrow_functions), [spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator), [destructuring assignment](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) and [enhanced object literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_Types#Enhanced_Object_literals). If you are unfamiliar with any of these concepts, click on any of the links to find out more. +Previous experience with other frameworks is a plus, but not required. Many of the examples make use of new language features in JavaScript such as: [arrow functions](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Functions/Arrow_functions), [spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator), [destructuring assignment](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) and [enhanced object literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_Types#Enhanced_Object_literals). If you are unfamiliar with any of these concepts, click on any of the links to find out more. - [Counter](/docs/counter.md) - [Countdown Timer](/docs/countdown-timer.md) diff --git a/docs/tweetbox.md b/docs/tweetbox.md index 4f252cb77..ab80df988 100644 --- a/docs/tweetbox.md +++ b/docs/tweetbox.md @@ -2,7 +2,7 @@ In this example we'll create a TweetBox clone and learn how to organize our UI code using [Components](/docs/components.md). -[Try it online](https://codepen.io/hyperapp/pen/bgWBdV?editors=0010) +[Try it Online](https://codepen.io/hyperapp/pen/bgWBdV?editors=0010) ```jsx const MAX_LENGTH = 120 @@ -131,7 +131,3 @@ By passing `OFFSET` into OverflowWidget we are able to slice `text` further and {text.slice(count)} </span> ``` - -<br /> - -[Back to Tutorials](/docs/tutorials.md) diff --git a/docs/view.md b/docs/view.md index a279603bc..81600d1e6 100644 --- a/docs/view.md +++ b/docs/view.md @@ -1,6 +1,6 @@ # View -Use the [view](/docs/api.md#view) to describe the user interface of your application. The view is a function called every time the state receives an update and returns a tree of [virtual nodes](/docs/virtual-nodes.md). +The [view](/docs/api.md#view) represents the user interface in your application. The view function is called every time the state receives an update and returns a [virtual node](/docs/virtual-nodes.md). ```jsx app({ @@ -14,16 +14,26 @@ app({ }) ``` -The view is passed the [actions](/docs/actions.md) object. Use it to bind actions to UI events. +The view receives the [actions](/docs/actions.md) object as the second argument. Use it to bind actions to UI events. + +[Try it Online](https://codepen.io/hyperapp/pen/zNxZLP?editors=0010) ```jsx app({ - state: 0, - view: (state, actions) => ( - <button onclick={actions.up}>{state}</button> - ), + state: { + count: 0 + }, + view: (state, actions) => + <main> + <h1>{state.count}</h1> + <button onclick={actions.sub}>ー</button> + <button onclick={actions.add}>+</button> + </main>, actions: { - up: state => state + 1 + sub: state => ({ count: state.count - 1 }), + add: state => ({ count: state.count + 1 }) } }) ``` + + diff --git a/docs/virtual-nodes.md b/docs/virtual-nodes.md index b01279de7..1a9e04f95 100644 --- a/docs/virtual-nodes.md +++ b/docs/virtual-nodes.md @@ -26,30 +26,40 @@ The virtual DOM engine consumes a virtual node and produces a DOM tree. </div> ``` -Use the [`h`](/docs/api.md#h) function to create virtual nodes. +Create virtual nodes with the [`h`](/docs/api.md#h) function. ```js -h("div", { id: "app" }, [ +const vnode = h("div", { id: "app" }, [ h("h1", null, "Hi.") ]) ``` -Or use [JSX](https://facebook.github.io/react/docs/jsx-in-depth.html) / [Hyperx](https://github.com/substack/hyperx) to create virtual nodes declaratively and compile them to `h` calls in a [build pipeline](/docs/getting-started.md#build-pipeline). +Or use [JSX](https://facebook.github.io/react/docs/jsx-in-depth.html) / [Hyperx](https://github.com/substack/hyperx). + +```jsx +const vnode = ( + <div id="app"> + <h1>Hi.</h1> + </div> +) +``` ## Attributes -Any valid HTML [attributes/properties](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes), [events](https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers), [styles](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference), etc. +Any valid [HTMLAttributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes), [SVGAttributes](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute), [DOMEvents](https://developer.mozilla.org/en-US/docs/Web/Events), [Lifecycle Events](/docs/lifecycle-events.md) or [keys](/docs/keys.md). -```js -data: { - id: "myButton", - class: "PrimaryButton", - onclick: () => alert("Hi."), - disabled: false, - style: { - fontSize: "3em" - } -} +```jsx +const MyButton = props => + <button + class="btn-large" + style={{ + fontSize: "5em", + color: "Tomato" + }} + onclick={props.doSomething} + > + {props.title} + </button> +) ``` -Attributes also include [lifecycle events](/docs/lifecycle-events.md) and meta data such as [keys](/docs/keys.md). diff --git a/package.json b/package.json index 929c41eae..32bed76e0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "module": "src/index.js", "license": "MIT", "repository": "hyperapp/hyperapp", - "files": ["src", "dist"], + "files": [ + "src", + "dist" + ], "author": "Jorge Bucaran", "keywords": [ "hyperapp", @@ -21,7 +24,9 @@ ], "scripts": { "test": "bundlesize && jest --coverage --no-cache", - "build": "rollup -cm -n hyperapp -f umd -i src/index.js -o dist/hyperapp.js", + "build": "npm run bundle && npm run minify", + "bundle": "rollup -i src/index.js -o dist/hyperapp.js -m -f umd -n hyperapp", + "minify": "uglifyjs dist/hyperapp.js -o dist/hyperapp.js --mangle --compress warnings=false --pure-funcs=Object.defineProperty -p relative --source-map dist/hyperapp.js.map", "prepublish": "npm run build", "format": "prettier --semi false --write 'src/**/*.js'", "release": "npm run build && npm test && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" @@ -35,7 +40,7 @@ "jest": "^20.0.4", "prettier": "~1.5.3", "rollup": "^0.45.2", - "rollup-plugin-uglify": "^2.0.1" + "uglify-js": "^2.7.5" }, "bundlesize": [ { diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index d996b589f..000000000 --- a/rollup.config.js +++ /dev/null @@ -1,12 +0,0 @@ -import uglify from "rollup-plugin-uglify" - -export default { - plugins: [ - uglify({ - compress: { - collapse_vars: true, - pure_funcs: ["Object.defineProperty"] - } - }) - ] -} diff --git a/src/app.js b/src/app.js index de4d0d89f..619291737 100644 --- a/src/app.js +++ b/src/app.js @@ -1,115 +1,78 @@ -export function app(app) { - var state = {} - var actions = {} - var events = {} - var mixins = [] - var view = app.view - var root = app.root || document.body - var node - var element - var locked = false - var loaded = false - - for (var i = -1; i < mixins.length; i++) { - var mixin = mixins[i] ? mixins[i](emit) : app - - Object.keys(mixin.events || []).map(function(key) { - events[key] = (events[key] || []).concat(mixin.events[key]) +var globalInvokeLaterStack = [] + +export function app(props) { + var appState + var appActions = {} + var appEvents = {} + var appMixins = [] + var appView = props.view + var prevNode + var appRoot = props.root + var willRender = false + + for (var i = -1; i < appMixins.length; i++) { + props = appMixins[i] ? appMixins[i](emit) : props + + Object.keys(props.events || []).map(function(key) { + appEvents[key] = (appEvents[key] || []).concat(props.events[key]) }) - if (mixin.state != null) { - state = merge(state, mixin.state) - } - - mixins = mixins.concat(mixin.mixins || []) + adaptActions(appActions, props.actions) - initialize(actions, mixin.actions) + appMixins = appMixins.concat(props.mixins || []) + appState = merge(appState, props.state) } - node = hydrate((element = root.querySelector("[data-ssr]")), [].map) - - repaint(emit("init")) + requestRender((prevNode = emit("load", appRoot))) return emit - function update(withState) { - if (withState != null) { - repaint((state = merge(state, emit("update", withState)))) - } + function render(cb) { + appRoot = patch( + (appRoot && appRoot.parentNode) || document.body, + appRoot, + prevNode, + (prevNode = emit("render", appView)(appState, appActions)), + (willRender = !willRender) + ) + while ((cb = globalInvokeLaterStack.pop())) cb() } - function repaint() { - if (!locked) { - requestAnimationFrame(render, (locked = !locked)) + function update(withState) { + if (withState) { + requestRender((appState = emit("update", merge(appState, withState)))) } + return appState } - function hydrate(element, map) { - return element == null - ? element - : { - tag: element.tagName, - data: {}, - children: map.call(element.childNodes, function(element) { - hydrate(element, map) - }) - } - } - - function render() { - element = patch( - root, - element, - node, - (node = emit("render", view)(state, actions)) - ) - - locked = !locked - - if (!loaded) { - emit("loaded", (loaded = true)) + function requestRender() { + if (appView && !willRender) { + requestAnimationFrame(render, (willRender = !willRender)) } } - function initialize(namespace, children, lastName) { + function adaptActions(namespace, children, lastName) { Object.keys(children || []).map(function(key) { var action = children[key] var name = lastName ? lastName + "." + key : key if (typeof action === "function") { namespace[key] = function(data) { - var result = emit("afterAction", { - name: name, - data: action( - state, - actions, - emit("beforeAction", { - name: name, - data: data - }).data - ) - }).data - - if (result == null) { - } else if (typeof result === "function") { - result = result(update) - } else if (typeof result.then === "function") { - result.then(update) - } else { - update(result) - } + emit("action", { name: name, data: data }) + + var result = emit("resolve", action(appState, appActions, data)) - return result + return typeof result === "function" ? result(update) : update(result) } } else { - initialize(namespace[key] || (namespace[key] = {}), action, name) + adaptActions(namespace[key] || (namespace[key] = {}), action, name) } }) } function emit(name, data) { - return (events[name] || []).map(function(cb) { - var result = cb(state, actions, data) + return (appEvents[name] || []).map(function(cb) { + var result = cb(appState, appActions, data) if (result != null) { data = result } @@ -117,10 +80,6 @@ export function app(app) { } function merge(a, b) { - if (typeof b !== "object") { - return b - } - var obj = {} for (var i in a) { @@ -148,33 +107,26 @@ export function app(app) { ? document.createElementNS("http://www.w3.org/2000/svg", node.tag) : document.createElement(node.tag) - for (var i = 0; i < node.children.length; ) { - element.appendChild(createElement(node.children[i++], isSVG)) + if (node.data && node.data.oncreate) { + globalInvokeLaterStack.push(function() { + node.data.oncreate(element) + }) } for (var i in node.data) { - if (i === "oncreate") { - node.data[i](element) - } else if (i === "oninsert") { - setTimeout(node.data[i], 0, element) - } else { - setElementData(element, i, node.data[i]) - } + setData(element, i, node.data[i]) + } + + for (var i = 0; i < node.children.length; ) { + element.appendChild(createElement(node.children[i++], isSVG)) } } return element } - function setElementData(element, name, value, oldValue) { - if ( - name === "key" || - name === "oncreate" || - name === "oninsert" || - name === "onupdate" || - name === "onremove" - ) { - return name + function setData(element, name, value, oldValue) { + if (name === "key") { } else if (name === "style") { for (var i in merge(oldValue, (value = value || {}))) { element.style[i] = value[i] || "" @@ -194,22 +146,20 @@ export function app(app) { } } - function updateElementData(element, oldData, data, cb) { - for (var name in merge(oldData, data)) { - var value = data[name] - var oldValue = oldData[name] - - if ( - value !== oldValue && - value !== element[name] && - setElementData(element, name, value, oldValue) == null - ) { - cb = data.onupdate + function updateElement(element, oldData, data) { + for (var i in merge(oldData, data)) { + var value = data[i] + var oldValue = i === "value" || i === "checked" ? element[i] : oldData[i] + + if (value !== oldValue) { + setData(element, i, value, oldValue) } } - if (cb != null) { - cb(element) + if (data && data.onupdate) { + globalInvokeLaterStack.push(function() { + data.onupdate(element, oldData) + }) } } @@ -221,29 +171,27 @@ export function app(app) { } } - function patch(parent, element, oldNode, node, isSVG, lastElement) { + function patch(parent, element, oldNode, node, isSVG, nextSibling) { if (oldNode == null) { element = parent.insertBefore(createElement(node, isSVG), element) } else if (node.tag != null && node.tag === oldNode.tag) { - updateElementData(element, oldNode.data, node.data) + updateElement(element, oldNode.data, node.data) isSVG = isSVG || node.tag === "svg" var len = node.children.length var oldLen = oldNode.children.length - var reusableChildren = {} + var oldKeyed = {} var oldElements = [] - var newKeys = {} + var keyed = {} for (var i = 0; i < oldLen; i++) { - var oldElement = element.childNodes[i] - oldElements[i] = oldElement - + var oldElement = (oldElements[i] = element.childNodes[i]) var oldChild = oldNode.children[i] var oldKey = getKey(oldChild) if (null != oldKey) { - reusableChildren[oldKey] = [oldElement, oldChild] + oldKeyed[oldKey] = [oldElement, oldChild] } } @@ -256,14 +204,14 @@ export function app(app) { var newChild = node.children[j] var oldKey = getKey(oldChild) - if (newKeys[oldKey]) { + if (keyed[oldKey]) { i++ continue } var newKey = getKey(newChild) - var reusableChild = reusableChildren[newKey] || [] + var keyedNode = oldKeyed[newKey] || [] if (null == newKey) { if (null == oldKey) { @@ -273,17 +221,17 @@ export function app(app) { i++ } else { if (oldKey === newKey) { - patch(element, reusableChild[0], reusableChild[1], newChild, isSVG) + patch(element, keyedNode[0], keyedNode[1], newChild, isSVG) i++ - } else if (reusableChild[0]) { - element.insertBefore(reusableChild[0], oldElement) - patch(element, reusableChild[0], reusableChild[1], newChild, isSVG) + } else if (keyedNode[0]) { + element.insertBefore(keyedNode[0], oldElement) + patch(element, keyedNode[0], keyedNode[1], newChild, isSVG) } else { patch(element, oldElement, null, newChild, isSVG) } j++ - newKeys[newKey] = newChild + keyed[newKey] = newChild } } @@ -296,19 +244,19 @@ export function app(app) { i++ } - for (var i in reusableChildren) { - var reusableChild = reusableChildren[i] - var reusableNode = reusableChild[1] - if (!newKeys[reusableNode.data.key]) { - removeElement(element, reusableChild[0], reusableNode.data) + for (var i in oldKeyed) { + var keyedNode = oldKeyed[i] + var reusableNode = keyedNode[1] + if (!keyed[reusableNode.data.key]) { + removeElement(element, keyedNode[0], reusableNode.data) } } - } else if ( - (lastElement = element) != null && - node !== oldNode && - node !== element.nodeValue - ) { - parent.replaceChild((element = createElement(node, isSVG)), lastElement) + } else if (element && node !== element.nodeValue) { + element = parent.insertBefore( + createElement(node, isSVG), + (nextSibling = element) + ) + removeElement(parent, nextSibling, oldNode.data) } return element diff --git a/src/h.js b/src/h.js index 4c634bd67..b915fc9a0 100644 --- a/src/h.js +++ b/src/h.js @@ -1,22 +1,24 @@ +var i +var stack = [] + export function h(tag, data) { var node - var stack = [] var children = [] - for (var i = arguments.length; i-- > 2; ) { - stack[stack.length] = arguments[i] + for (i = arguments.length; i-- > 2; ) { + stack.push(arguments[i]) } while (stack.length) { if (Array.isArray((node = stack.pop()))) { - for (var i = node.length; i--; ) { - stack[stack.length] = node[i] + for (i = node.length; i--; ) { + stack.push(node[i]) } } else if (node != null && node !== true && node !== false) { if (typeof node === "number") { node = node + "" } - children[children.length] = node + children.push(node) } } diff --git a/test/actions.test.js b/test/actions.test.js index 811ed874b..9b275037a 100644 --- a/test/actions.test.js +++ b/test/actions.test.js @@ -1,158 +1,175 @@ import { h, app } from "../src" +const mockDelay = () => new Promise(resolve => setTimeout(resolve, 50)) + window.requestAnimationFrame = setTimeout -beforeEach(() => (document.body.innerHTML = "")) +beforeEach(() => { + document.body.innerHTML = "" +}) -test("namespaced/nested actions", () => { +test("namespacing", done => { app({ - state: true, view: state => "", actions: { foo: { bar: { - baz: (state, actions, data) => { - expect(state).toBe(true) + baz(state, actions, data) { expect(data).toBe("foo.bar.baz") + done() } } } }, events: { - init: (state, actions) => actions.foo.bar.baz("foo.bar.baz") + load(state, actions) { + actions.foo.bar.baz("foo.bar.baz") + } } }) }) -test("update the state sync", done => { +test("sync updates", done => { app({ - state: 1, - view: state => h("div", null, state), + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe(`<div>2</div>`) + done() + } + }, + state.value + ), + state: { + value: 1 + }, actions: { - add: state => state + 1 + up(state) { + return { + value: state.value + 1 + } + } }, events: { - init: (state, actions) => { - actions.add() - }, - loaded: () => { - expect(document.body.innerHTML).toBe(`<div>2</div>`) - done() + load(state, actions) { + actions.up() } } }) }) -test("update the state async", done => { +test("async updates", done => { app({ - state: 1, - view: state => h("div", null, state), - actions: { - change: (state, actions, data) => state + data, - delayAndChange: (state, actions, data) => { - setTimeout(() => { - actions.change(data) - setTimeout(() => { - expect(document.body.innerHTML).toBe(`<div>${state + data}</div>`) + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe(`<div>2</div>`) + }, + onupdate() { + expect(document.body.innerHTML).toBe(`<div>3</div>`) done() - }) - }, 20) - } + } + }, + state.value + ), + state: { + value: 2 }, - events: { - init: (state, actions) => actions.delayAndChange(100) - } - }) -}) - -test("update the state async using a promise with handler", done => { - app({ - state: 1, - view: state => h("div", {}, state), actions: { - delay: state => new Promise(resolve => setTimeout(() => resolve(), 20)), - change: (state, actions, data) => state + data, - delayAndChange: (state, actions, data) => { - actions.delay().then(() => { - actions.change(data) - setTimeout(() => { - expect(document.body.innerHTML).toBe(`<div>${state + data}</div>`) - done() - }) + up(state, actions, byNumber) { + return { + value: state.value + byNumber + } + }, + upAsync(state, actions, byNumber) { + mockDelay().then(() => { + actions.up(byNumber) }) } }, events: { - init: (state, actions) => actions.delayAndChange(100) + load(state, actions) { + actions.upAsync(1) + } } }) }) -test("update the state async using a thunk", done => { +test("thunks", done => { app({ - state: 1, - view: state => h("div", {}, state), - actions: { - delay: state => new Promise(resolve => setTimeout(() => resolve(), 20)), - delayAndChange: (state, actions, data) => (update) => { - actions.delay().then(() => { - update(state + data) - - setTimeout(() => { - expect(document.body.innerHTML).toBe(`<div>${state + data}</div>`) + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe(`<div>3</div>`) + }, + onupdate() { + expect(document.body.innerHTML).toBe(`<div>4</div>`) done() - }) - }) - } + } + }, + state.value + ), + state: { + value: 3 }, - events: { - init: (state, actions) => actions.delayAndChange(100) - } - }) -}) - -test("update the state async using a promise", done => { - app({ - state: 1, - view: state => h("div", {}, state), actions: { - delay: state => new Promise(resolve => setTimeout(() => resolve(), 20)), - delayAndChange: (state, actions, data) => { - return actions.delay().then(() => { - return state + data - }) + upAsync(state, actions, data) { + return update => { + mockDelay().then(() => { + update({ value: state.value + data }) + }) + } } }, events: { - init: (state, actions) => actions.delayAndChange(100), - render: () => { - setTimeout(() => { - expect(document.body.innerHTML).toBe(`<div>101</div>`) - done() - }) + load(state, actions) { + actions.upAsync(1) } } }) }) -test("update a state using then sync", done => { +test("thunks + promises", done => { app({ + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe(`<div>4</div>`) + }, + onupdate() { + expect(document.body.innerHTML).toBe(`<div>5</div>`) + done() + } + }, + state.value + ), state: { - then: 1 + value: 4 }, - view: state => h("div", null, state.then), actions: { - add: state => ({ then: state.then + 1 }) + upAsync(state, actions, data) { + return mockDelay().then(() => ({ + value: state.value + data + })) + } }, events: { - init: (state, actions) => { - actions.add() + load(state, actions) { + actions.upAsync(1) }, - loaded: () => { - expect(document.body.innerHTML).toBe(`<div>2</div>`) - done() + resolve(state, actions, result) { + return result && typeof result.then === "function" + ? update => result.then(update) + : result } } }) }) - diff --git a/test/app.test.js b/test/app.test.js index 39944e485..bc78c933e 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -2,132 +2,66 @@ import { h, app } from "../src" window.requestAnimationFrame = setTimeout -test("send messages to app", done => { - const tellApp = app({ - state: 0, - view: state => h("div", null, state), - actions: { - set: (state, actions, data) => data - }, - events: { - set: (state, actions, data) => actions.set(data), - loaded: () => { - expect(document.body.innerHTML).toBe(`<div>foo</div>`) - done() - } - } - }) - - tellApp("set", "foo") +beforeEach(() => { + document.body.innerHTML = "" }) -test("throttled renders", done => { +test("throttling", done => { app({ - state: 0, - view: state => h("div", null, state), + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe("<div>5</div>") + done() + } + }, + state.value + ), + state: { + value: 1 + }, actions: { - up: state => state + 1, - fire: (state, actions) => { - actions.up() - actions.up() - actions.up() + up(state) { + return { + value: state.value + 1 + } } }, events: { - init: (state, actions) => actions.fire(), - render: state => { - expect(state).toBe(3) - done() - } - } - }) -}) - -test("hydrate from SSR", done => { - document.body.innerHTML = `<div id="foo" data-ssr><div id="bar">Baz</div></div>` - - app({ - state: {}, - view: state => h("div", { id: "foo" }, [h("div", { id: "bar" }, ["Baz"])]), - events: { - loaded: () => { - expect(document.body.innerHTML).toBe( - `<div id="foo"><div id="bar">Baz</div></div>` - ) - done() - } - } - }) -}) - -test("hydrate with outdated textnode from SSR", done => { - document.body.innerHTML = `<div id="foo" data-ssr>Foo</div>` - - app({ - view: state => h("div", { id: "foo" }, "Bar"), - events: { - loaded: () => { - expect(document.body.innerHTML).toBe(`<div id="foo">Bar</div>`) - done() + load(state, actions) { + actions.up() + actions.up() + actions.up() + actions.up() + }, + render(state) { + // + // Because renders are throttled this event is called only once. + // + expect(state).toEqual({ value: 5 }) } } }) }) -test("hydrate from SSR with a root", done => { - document.body.innerHTML = `<div id="app"><div id="foo" data-ssr>Foo</div></div>` - - app({ - root: document.getElementById("app"), - state: {}, - view: state => h("div", { id: "foo" }, ["Foo"]), +test("interop", done => { + const emit = app({ events: { - loaded: () => { - expect(document.body.innerHTML).toBe( - `<div id="app"><div id="foo">Foo</div></div>` - ) + foo(state, actions, data) { + expect(data).toBe("bar") done() } } }) + emit("foo", "bar") }) -test("svg", done => { - const SVG_NS = "http://www.w3.org/2000/svg" - +test("optional view", done => { app({ - view: () => - h("div", {}, [ - h("p", { id: "foo" }, "foo"), - h("svg", { id: "bar", viewBox: "0 0 10 10" }, [ - h("quux", {}, [ - h("beep", {}, [h("ping", {}), h("pong", {})]), - h("bop", {}), - h("boop", {}, [h("ping", {}), h("pong", {})]) - ]), - h("xuuq", {}, [ - h("beep", {}), - h("bop", {}, [h("ping", {}), h("pong", {})]), - h("boop", {}) - ]) - ]), - h("p", { id: "baz" }, "baz") - ]), events: { - loaded: () => { - expect(document.getElementById("foo").namespaceURI).not.toBe(SVG_NS) - expect(document.getElementById("baz").namespaceURI).not.toBe(SVG_NS) - - const svg = document.getElementById("bar") - expect(svg.namespaceURI).toBe(SVG_NS) - expect(svg.getAttribute("viewBox")).toBe("0 0 10 10") - expectChildren(svg) - - function expectChildren(svgElement) { - Array.from(svgElement.childNodes).forEach(node => - expectChildren(node, expect(node.namespaceURI).toBe(SVG_NS)) - ) - } + load() { done() } } diff --git a/test/events.test.js b/test/events.test.js index d4341e4fa..a5186dc4d 100644 --- a/test/events.test.js +++ b/test/events.test.js @@ -2,150 +2,178 @@ import { h, app } from "../src" window.requestAnimationFrame = setTimeout -beforeEach(() => (document.body.innerHTML = "")) +beforeEach(() => { + document.body.innerHTML = "" +}) -test("init", () => { +test("load", done => { app({ - view: state => "", - state: 1, + state: { + value: "foo" + }, actions: { - step: state => state + 1 + set(state, actions, value) { + return { value } + } }, events: { - init: [ - (state, actions) => actions.step(), - (state, actions) => actions.step(), - state => expect(state).toBe(3) - ] - } - }) -}) - -test("loaded", done => { - app({ - state: "foo", - view: state => h("div", null, state), - events: { - init: () => { - expect(document.body.innerHTML).toBe("") + load(state, actions) { + actions.set("bar") }, - loaded: () => { - expect(document.body.innerHTML).toBe(`<div>foo</div>`) + update(state, actions, nextState) { + expect(state.value).toBe("foo") + expect(nextState.value).toBe("bar") done() } } }) }) -test("beforeAction", done => { +test("render", done => { app({ - state: "", - view: state => h("div", null, state), - actions: { - set: (state, actions, data) => data + state: { + value: "foo" }, + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe(`<main><div>foo</div></main>`) + done() + } + }, + state.value + ), events: { - init: (state, actions) => { - actions.set("foo") - }, - loaded: () => { - expect(document.body.innerHTML).toBe(`<div>bar</div>`) - done() - }, - beforeAction: (state, actions, { name, data }) => { - if (name === "set") { - return { data: "bar" } - } + render(state, actions, view) { + return state => h("main", {}, view(state, actions)) } } }) }) -test("afterAction", done => { +test("action", done => { app({ - state: "", - view: state => h("div", null, state), + view: state => + h( + "div", + { + oncreate() { + expect(state).toEqual({ value: "bar" }) + expect(document.body.innerHTML).toBe(`<div>bar</div>`) + done() + } + }, + state.value + ), + state: { + value: "foo" + }, actions: { - set: (state, actions, data) => "bar" + set(state, actions, value) { + return { value } + } }, events: { - init: (state, actions) => { - actions.set("foo") - }, - loaded: () => { - expect(document.body.innerHTML).toBe(`<div>baz</div>`) - done() + load(state, actions) { + actions.set("bar") }, - afterAction: (state, actions, { name, data }) => { - if (name === "set") { - return { data: "baz" } - } + action(state, actions, { name, data }) { + expect(name).toBe("set") + expect(data).toBe("bar") } } }) }) -test("update", done => { +test("resolve", done => { app({ - state: 1, - view: state => h("div", null, state), + view: state => + h( + "div", + { + oncreate() { + expect(state).toEqual({ value: "bar" }) + expect(document.body.innerHTML).toBe(`<div>bar</div>`) + done() + } + }, + state.value + ), + state: { + value: "foo" + }, actions: { - add: state => state + 1 + set(state, actions, data) { + return `?value=bar` + } }, events: { - init: (state, actions) => actions.add(), - loaded: () => { - expect(document.body.innerHTML).toBe(`<div>20</div>`) - done() + load(state, actions) { + actions.set("bar") }, - update: (state, actions, data) => data * 10 - } - }) -}) - -test("render", done => { - app({ - state: 1, - view: state => h("div", null, state), - events: { - loaded: () => { - expect(document.body.innerHTML).toBe(`<main><div>1</div></main>`) - done() - }, - render: (state, actions, view) => state => - h("main", null, view(state, actions)) - } - }) -}) - -test("custom event", () => { - const emit = app({ - view: state => "", - events: { - foo: (state, actions, data) => expect("foo").toBe(data) + resolve(state, actions, result) { + if (typeof result === "string") { + // + // Query strings as a valid ActionResult. + // + const [key, value] = result.slice(1).split("=") + return { [key]: value } + } + } } }) - - emit("foo", "foo") }) -test("nested action name", () => { +test("update", done => { app({ - view: state => "", - state: "", + view: state => + h( + "div", + { + oncreate() { + expect(state).toEqual({ value: "foo" }) + expect(document.body.innerHTML).toBe(`<div>foo</div>`) + done() + } + }, + state.value + ), + state: { + value: "foo" + }, actions: { - foo: { - bar: { - set: (state, actions, data) => data - } + set(state, actions, value) { + return { value } } }, events: { - init: (state, actions) => actions.foo.bar.set("foobar"), - action: (state, actions, { name, data }) => { - expect(name).toBe("foo.bar.set") - expect(data).toBe("foobar") + load(state, actions) { + actions.set(null) + }, + update(state, actions, nextState) { + if (typeof nextState.value !== "string") { + return state + } } } }) }) + +// test("ready", done => { +// app({ +// view: state => h("div", {}, "foo"), +// events: { +// ready(state, actions, root) { +// // +// // This event fires after the view is rendered and attached +// // to the DOM with your app top-level element / root. +// // +// root.appendChilde(document.createTextNode("bar")) +// expect(document.body.innerHTML).toBe(`<div>foobar</div>`) +// done() +// } +// } +// }) +// }) diff --git a/test/h.test.js b/test/h.test.js index 836fa2ff8..79641086c 100644 --- a/test/h.test.js +++ b/test/h.test.js @@ -75,9 +75,7 @@ test("skip null and Boolean children", () => { } expect(h("div", {}, true)).toEqual(expected) - expect(h("div", {}, false)).toEqual(expected) - expect(h("div", {}, null)).toEqual(expected) }) diff --git a/test/hydration.test.js b/test/hydration.test.js new file mode 100644 index 000000000..bd0395a7d --- /dev/null +++ b/test/hydration.test.js @@ -0,0 +1,77 @@ +import { h, app } from "../src" + +window.requestAnimationFrame = setTimeout + +// +// Naive hydration. Doesn't handle contiguous empty text nodes. +// +const hydrate = node => + node + ? { + tag: node.tagName.toLowerCase(), + data: {}, + children: Array.prototype.map.call( + node.childNodes, + node => (node.nodeType === 3 ? node.nodeValue : hydrate(node)) + ) + } + : node + +beforeEach(() => { + document.body.innerHTML = "" +}) + +test("hydrate from SSR", done => { + document.body.innerHTML = `<div id="foo" data-ssr>foo</div>` + + app({ + root: document.querySelector("[data-ssr]"), + view: state => + h( + "div", + { + onupdate() { + // + // Careful: oncreate doesn't fire in hydrated nodes! + // + expect(document.body.innerHTML).toBe( + `<div id="foo" data-ssr="">foo</div>` + ) + done() + } + }, + "foo" + ), + events: { + load(state, actions, root) { + return hydrate(root) + } + } + }) +}) + +test("hydrate from SSR with out-of-date text node", done => { + document.body.innerHTML = `<div id="foo" data-ssr>foo</div>` + + app({ + view: state => + h( + "div", + { + onupdate() { + expect(document.body.innerHTML).toBe( + `<div id="foo" data-ssr="">bar</div>` + ) + done() + } + }, + "bar" + ), + root: document.querySelector("[data-ssr]"), + events: { + load(state, actions, root) { + return hydrate(root) + } + } + }) +}) diff --git a/test/lifecycle.test.js b/test/lifecycle.test.js index 46f47ab5e..4e46f431e 100644 --- a/test/lifecycle.test.js +++ b/test/lifecycle.test.js @@ -2,99 +2,152 @@ import { h, app } from "../src" window.requestAnimationFrame = setTimeout -const getElementByTagName = tag => document.getElementsByTagName(tag)[0] - -beforeEach(() => (document.body.innerHTML = "")) +beforeEach(() => { + document.body.innerHTML = "" +}) test("oncreate", done => { app({ view: () => - h("div", { - oncreate: element => { - setTimeout(() => {}) - - expect(element).not.toBe(undefined) - expect(getElementByTagName("div")).toBe(undefined) - - setTimeout(() => { - expect(getElementByTagName("div")).toBe(element) + h( + "div", + { + oncreate(element) { + element.className = "foo" + expect(document.body.innerHTML).toBe(`<div class="foo">foo</div>`) done() - }) - } - }) + } + }, + "foo" + ) }) }) -test("oninsert", done => { +test("onupdate", done => { app({ - view: () => - h("div", { - oninsert: element => { - expect(getElementByTagName("div")).toBe(element) - done() - } - }) - }) -}) - -test("fire onupdate if node data changes", done => { - app({ - state: "foo", - view: state => - h("div", { - class: state, - onupdate: done - }), + view: (state, actions) => + h( + "div", + { + class: state.value, + oncreate() { + actions.repaint() + }, + onupdate(element, oldProps) { + // + // onupdate fires after the element's data is updated and + // the element is patched. Note that we call this event + // even if the element's data didn't change. + // + expect(element.textContent).toBe("foo") + expect(oldProps.class).toBe("foo") + done() + } + }, + state.value + ), + state: { value: "foo" }, actions: { - change: state => "bar" - }, - events: { - loaded: (state, actions) => { - actions.change() + repaint(state) { + return state } } }) }) -test("do not fire onupdate if data does not change", () => { - const noop = () => {} - - return new Promise((resolve, reject) => { - app({ - state: "foo", - view: state => - h("div", { - class: state, - oncreate: noop, - onupdate: reject, - oninsert: noop, - onremove: noop - }), - actions: { - change: state => "foo" - }, - events: { - loaded: (state, actions) => { - actions.change() - setTimeout(resolve, 100) +test("onremove", done => { + app({ + view: (state, actions) => + state.value + ? h( + "ul", + { + oncreate() { + expect(document.body.innerHTML).toBe( + "<ul><li></li><li></li></ul>" + ) + actions.toggle() + } + }, + [ + h("li"), + h("li", { + onremove(element) { + // + // Be sure to remove the element inside this event. + // + element.parentNode.removeChild(element) + expect(document.body.innerHTML).toBe("<ul><li></li></ul>") + done() + } + }) + ] + ) + : h("ul", {}, [h("li")]), + state: { + value: true + }, + actions: { + toggle(state) { + return { + value: !state.value } } - }) + } }) }) -test("onremove", done => { +test("event bubling", done => { + let count = 0 + app({ - state: true, - view: state => - state - ? h("ul", {}, [h("li"), h("li", { onremove: done })]) - : h("ul", {}, [h("li")]), - actions: { - toggle: state => !state + state: { + value: true }, - events: { - loaded: (state, actions) => actions.toggle() + view: (state, actions) => + h( + "main", + { + oncreate() { + expect(count++).toBe(3) + actions.update() + }, + onupdate() { + expect(count++).toBe(7) + done() + } + }, + [ + h("p", { + oncreate() { + expect(count++).toBe(2) + }, + onupdate() { + expect(count++).toBe(6) + } + }), + h("p", { + oncreate() { + expect(count++).toBe(1) + }, + onupdate() { + expect(count++).toBe(5) + } + }), + h("p", { + oncreate() { + expect(count++).toBe(0) + }, + onupdate() { + expect(count++).toBe(4) + } + }) + ] + ), + actions: { + update(state) { + return { value: !state.value } + } } }) }) diff --git a/test/mixins.test.js b/test/mixins.test.js index 38faf0069..68fb97af8 100644 --- a/test/mixins.test.js +++ b/test/mixins.test.js @@ -2,161 +2,186 @@ import { h, app } from "../src" window.requestAnimationFrame = setTimeout -beforeEach(() => (document.body.innerHTML = "")) - -test("extend the state", () => { - const mixin = () => ({ - state: { - bar: true - } - }) +beforeEach(() => { + document.body.innerHTML = "" +}) +test("extend the state", done => { app({ state: { foo: true }, - view: state => "", events: { - init: state => { + load: state => { expect(state).toEqual({ foo: true, bar: true }) + done() } }, - mixins: [mixin] + mixins: [ + () => ({ + state: { + bar: true + } + }) + ] }) }) -test("extend events", () => { - let count = 0 - - const A = () => ({ - events: { - init: () => expect(++count).toBe(2) - } - }) - - const B = () => ({ - events: { - init: () => expect(++count).toBe(3) - } - }) - +test("extend events", done => { app({ - view: state => "", - events: { - init: () => expect(++count).toBe(1) + state: { + value: 0 }, - mixins: [A, B] - }) -}) - -test("extend actions", () => { - const mixin = app => ({ actions: { - foo: { - bar: { - baz: { - toggle: state => !state - } + up(state) { + return { + value: state.value + 1 } } - } - }) - - app({ - state: true, - view: state => h("div", null, state), + }, events: { - loaded: (state, actions) => { - expect(document.body.innerHTML).toBe(`<div>true</div>`) - - actions.foo.bar.baz.toggle() - - setTimeoout(() => { - expect(document.body.innerHTML).toBe(`<div>false</div>`) - }) + load(state, actions) { + expect(state.value).toBe(0) + actions.up() } }, - mixins: [mixin] + mixins: [ + () => ({ + events: { + load(state, actions) { + expect(state.value).toBe(1) + actions.up() + } + } + }), + () => ({ + events: { + load(state) { + expect(state.value).toBe(2) + done() + } + } + }) + ] }) }) -test("don't overwrite actions in the same namespace", () => { - const mixin = app => ({ - actions: { - foo: { - bar: { - baz: (state, actions, data) => { - expect(state).toBe(true) - expect(data).toBe("foo.bar.baz") - return state +test("extend actions", done => { + app({ + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe(`<div>foo</div>`) + done() } - } + }, + state.value + ), + state: { + value: "" + }, + events: { + load(state, actions) { + actions.foo() } - } + }, + mixins: [ + () => ({ + actions: { + foo() { + return { + value: "foo" + } + } + } + }) + ] }) +}) +test("extend namespace", done => { app({ - state: true, - view: state => "", actions: { foo: { - bar: { - qux: (state, actions, data) => { - expect(state).toBe(true) - expect(data).toBe("foo.bar.qux") - } + bar(state, actions, data) { + expect(data).toBe(true) } } }, events: { - init: [ - (state, actions) => actions.foo.bar.baz("foo.bar.baz"), - (state, actions) => actions.foo.bar.qux("foo.bar.qux") - ] + load(state, actions) { + actions.foo.bar(true) + actions.foo.baz(true) + actions.foo.quux.ping(true) + done() + } }, - mixins: [mixin] + mixins: [ + () => ({ + actions: { + foo: { + baz(state, actions, data) { + expect(data).toBe(true) + }, + quux: { + ping(state, actions, data) { + expect(data).toBe(true) + } + } + } + } + }) + ] }) }) -test("mixin composition", () => { - const A = () => ({ - state: { - foo: 1 - } - }) - - const B = () => ({ - mixins: [A], - state: { - bar: 2 - } +test("presets", () => { + const foobar = () => ({ + mixins: [ + () => ({ + state: { + foo: 1 + } + }), + () => ({ + state: { + bar: 2 + } + }) + ] }) app({ - mixins: [B], - view: () => "", events: { - init: state => { - expect(state.bar).toBe(2) + load(state) { expect(state.foo).toBe(1) + expect(state.bar).toBe(2) } - } + }, + mixins: [foobar] }) }) test("receive emit function", done => { app({ + events: { + foo() { + done() + } + }, mixins: [ emit => ({ - events: { init: () => emit("foo") } + events: { + load() { + emit("foo") + } + } }) - ], - view: () => "", - events: { - foo: done - } + ] }) }) diff --git a/test/root.test.js b/test/root.test.js index ff0023873..db375da29 100644 --- a/test/root.test.js +++ b/test/root.test.js @@ -2,77 +2,126 @@ import { h, app } from "../src" window.requestAnimationFrame = setTimeout -beforeEach(() => (document.body.innerHTML = "")) +beforeEach(() => { + document.body.innerHTML = "" +}) -test("document.body is the default root", done => { +test("no root", done => { app({ - view: state => "foo", - events: { - loaded: () => { - expect(document.body.innerHTML).toBe("foo") - done() - } - } + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe("<div>foo</div>") + done() + } + }, + "foo" + ) }) }) -test("root", done => { +test("given root", done => { + document.body.innerHTML = "<main></main>" + app({ - view: state => h("div", null, "foo"), - events: { - loaded: () => { - expect(document.body.innerHTML).toBe(`<main><div>foo</div></main>`) - done() - } - }, - root: document.body.appendChild(document.createElement("main")) + root: document.body.firstChild, + view: state => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe("<div>foo</div>") + done() + } + }, + "foo" + ) }) }) -test("non-empty root", done => { - - const main = document.createElement("main") - main.appendChild(document.createElement("span")) +test("root as document.body", done => { + document.body.innerHTML = "<div></div>" app({ - view: state => h("div", null, "foo"), - events: { - loaded: () => { - expect(document.body.innerHTML).toBe( - `<main><span></span><div>foo</div></main>` - ) - done() - } - }, - root: document.body.appendChild(main) + root: document.body, + view: state => + h( + "body", + { + id: "foo", + oncreate() { + expect(document.body.id).toBe("foo") + expect(document.body.innerHTML).toBe("<main>foo</main>") + done() + } + }, + [h("main", {}, "foo")] + ) }) }) -test("mutated root", done => { - const main = document.createElement("main") +test("root nested inside another element", done => { + document.body.innerHTML = "<main><section></section><div></div></main>" app({ - state: "foo", - view: state => h("div", null, state), - root: document.body.appendChild(main), - actions: { - bar: state => "bar" - }, - events: { - loaded: (state, actions) => { - expect(document.body.innerHTML).toBe(`<main><div>foo</div></main>`) + root: document.body.firstChild.lastChild, + view: state => + h( + "p", + { + oncreate() { + expect(document.body.innerHTML).toBe( + `<main><section></section><p>foo</p></main>` + ) + done() + } + }, + "foo" + ) + }) +}) - main.insertBefore(document.createElement("header"), main.firstChild) - main.appendChild(document.createElement("footer")) +test("root with mutated host", done => { + document.body.innerHTML = "<main><div></div></main>" - actions.bar() + const host = document.body.firstChild + const root = host.firstChild - setTimeout(() => { - expect(document.body.innerHTML).toBe( - `<main><header></header><div>bar</div><footer></footer></main>` - ) - done() - }) + app({ + root, + view: (state, actions) => + h( + "div", + { + oncreate() { + expect(document.body.innerHTML).toBe(`<main><div>foo</div></main>`) + // + // We should be able to patch the root even if the host is mutated. + // + host.insertBefore(document.createElement("header"), host.firstChild) + host.appendChild(document.createElement("footer")) + + actions.bar() + }, + onupdate() { + expect(document.body.innerHTML).toBe( + `<main><header></header><div>bar</div><footer></footer></main>` + ) + done() + } + }, + state.value + ), + state: { + value: "foo" + }, + actions: { + bar(state) { + return { + value: "bar" + } } } }) diff --git a/test/svg.test.js b/test/svg.test.js new file mode 100644 index 000000000..b319f3a1d --- /dev/null +++ b/test/svg.test.js @@ -0,0 +1,51 @@ +import { h, app } from "../src" + +window.requestAnimationFrame = setTimeout + +const SVG_NS = "http://www.w3.org/2000/svg" + +const deepExpectNS = (element, ns) => + Array.from(element.childNodes).map(child => { + expect(child.namespaceURI).toBe(ns) + deepExpectNS(child, ns) + }) + +test("svg", done => { + app({ + view: () => + h( + "div", + { + oncreate() { + const foo = document.getElementById("foo") + const bar = document.getElementById("bar") + const baz = document.getElementById("baz") + + expect(foo.namespaceURI).not.toBe(SVG_NS) + expect(baz.namespaceURI).not.toBe(SVG_NS) + expect(bar.namespaceURI).toBe(SVG_NS) + expect(bar.getAttribute("viewBox")).toBe("0 0 10 10") + deepExpectNS(bar, SVG_NS) + + done() + } + }, + [ + h("p", { id: "foo" }, "foo"), + h("svg", { id: "bar", viewBox: "0 0 10 10" }, [ + h("quux", {}, [ + h("beep", {}, [h("ping", {}), h("pong", {})]), + h("bop", {}), + h("boop", {}, [h("ping", {}), h("pong", {})]) + ]), + h("xuuq", {}, [ + h("beep", {}), + h("bop", {}, [h("ping", {}), h("pong", {})]), + h("boop", {}) + ]) + ]), + h("p", { id: "baz" }, "baz") + ] + ) + }) +}) diff --git a/test/vdom.test.js b/test/vdom.test.js index 077775ff5..dfe8a1c3b 100644 --- a/test/vdom.test.js +++ b/test/vdom.test.js @@ -1,118 +1,158 @@ import { h, app } from "../src" -window.requestAnimationFrame = setTimeout - -beforeEach(() => (document.body.innerHTML = "")) - -const TreeTest = trees => { - return new Promise((resolve, reject) => { - const NextTree = (index, up) => { - if (trees.length === index) { - resolve() - } - - try { - expect(document.body.innerHTML).toBe( - trees[index].html.replace(/\s{2,}/g, "") - ) - } catch (error) { - reject(error) - } - - setTimeout(NextTree, 0, up(), up) - } - +function testTrees(name, trees) { + test(name, done => { app({ - state: 0, - view: index => trees[index].tree, - actions: { - up: index => index + 1 + root: document.body, + view: (state, actions) => + h( + "body", + { + oncreate: actions.next, + onupdate: actions.next + }, + [trees[state.index].tree] + ), + state: { + index: 0 }, - events: { - loaded: (index, { up }) => NextTree(index, up) + actions: { + up(state) { + return { index: state.index + 1 } + }, + next(state, actions) { + expect(document.body.innerHTML).toBe( + trees[state.index].html.replace(/\s{2,}/g, "") + ) + + if (state.index === trees.length - 1) { + return done() + } + + actions.up() + } } }) }) } -test("replace element", () => - TreeTest([ - { - tree: h("main"), - html: `<main></main>` - }, - { - tree: h("div"), - html: `<div></div>` - } - ])) +window.requestAnimationFrame = setTimeout + +beforeEach(() => { + document.body.innerHTML = "" +}) -test("replace child", () => - TreeTest([ - { - tree: h("main", null, [h("div", null, ["foo"])]), - html: ` +testTrees("replace element", [ + { + tree: h("main", {}), + html: `<main></main>` + }, + { + tree: h("div", {}), + html: `<div></div>` + } +]) + +testTrees("replace child", [ + { + tree: h("main", {}, [h("div", {}, "foo")]), + html: ` <main> <div>foo</div> </main> ` - }, - { - tree: h("main", null, [h("main", null, ["bar"])]), - html: ` + }, + { + tree: h("main", {}, [h("main", {}, "bar")]), + html: ` <main> <main>bar</main> </main> ` - } - ])) + } +]) -test("insert children on top", () => - TreeTest([ - { - tree: h("main", {}, [ - h("div", { key: "a", oncreate: e => (e.id = "a") }, "A") - ]), - html: ` +testTrees("insert children on top", [ + { + tree: h("main", {}, [ + h( + "div", + { + key: "a", + oncreate(e) { + e.id = "a" + } + }, + "A" + ) + ]), + html: ` <main> <div id="a">A</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "b", oncreate: e => (e.id = "b") }, "B"), - h("div", { key: "a" }, "A") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h( + "div", + { + key: "b", + oncreate(e) { + e.id = "b" + } + }, + "B" + ), + h("div", { key: "a" }, "A") + ]), + html: ` <main> <div id="b">B</div> <div id="a">A</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "c", oncreate: e => (e.id = "c") }, "C"), - h("div", { key: "b" }, "B"), - h("div", { key: "a" }, "A") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h( + "div", + { + key: "c", + oncreate(e) { + e.id = "c" + } + }, + "C" + ), + h("div", { key: "b" }, "B"), + h("div", { key: "a" }, "A") + ]), + html: ` <main> <div id="c">C</div> <div id="b">B</div> <div id="a">A</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "d", oncreate: e => (e.id = "d") }, "D"), - h("div", { key: "c" }, "C"), - h("div", { key: "b" }, "B"), - h("div", { key: "a" }, "A") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h( + "div", + { + key: "d", + oncreate(e) { + e.id = "d" + } + }, + "D" + ), + h("div", { key: "c" }, "C"), + h("div", { key: "b" }, "B"), + h("div", { key: "a" }, "A") + ]), + html: ` <main> <div id="d">D</div> <div id="c">C</div> @@ -120,65 +160,125 @@ test("insert children on top", () => <div id="a">A</div> </main> ` - } - ])) + } +]) -test("remove text node", () => - TreeTest([ - { - tree: h("main", {}, [h("div", {}, ["foo"]), "bar"]), - html: ` +testTrees("remove text node", [ + { + tree: h("main", {}, [h("div", {}, ["foo"]), "bar"]), + html: ` <main> <div>foo</div> bar </main> ` - }, - { - tree: h("main", {}, [h("div", {}, ["foo"])]), - html: ` + }, + { + tree: h("main", {}, [h("div", {}, ["foo"])]), + html: ` <main> <div>foo</div> </main> ` - } - ])) + } +]) -test("replace keyed", () => - TreeTest([ - { - tree: h("main", {}, [ - h("div", { key: "a", oncreate: e => (e.id = "a") }, "A") - ]), - html: ` +testTrees("replace keyed", [ + { + tree: h("main", {}, [ + h( + "div", + { + key: "a", + oncreate(e) { + e.id = "a" + } + }, + "A" + ) + ]), + html: ` <main> <div id="a">A</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "b", oncreate: e => (e.id = "b") }, "B") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h( + "div", + { + key: "b", + oncreate(e) { + e.id = "b" + } + }, + "B" + ) + ]), + html: ` <main> <div id="b">B</div> </main> ` - } - ])) + } +]) -test("reorder keyed", () => - TreeTest([ - { - tree: h("main", {}, [ - h("div", { key: "a", oncreate: e => (e.id = "a") }, "A"), - h("div", { key: "b", oncreate: e => (e.id = "b") }, "B"), - h("div", { key: "c", oncreate: e => (e.id = "c") }, "C"), - h("div", { key: "d", oncreate: e => (e.id = "d") }, "D"), - h("div", { key: "e", oncreate: e => (e.id = "e") }, "E") - ]), - html: ` +testTrees("reorder keyed", [ + { + tree: h("main", {}, [ + h( + "div", + { + key: "a", + oncreate(e) { + e.id = "a" + } + }, + "A" + ), + h( + "div", + { + key: "b", + oncreate(e) { + e.id = "b" + } + }, + "B" + ), + h( + "div", + { + key: "c", + oncreate(e) { + e.id = "c" + } + }, + "C" + ), + h( + "div", + { + key: "d", + oncreate(e) { + e.id = "d" + } + }, + "D" + ), + h( + "div", + { + key: "e", + oncreate(e) { + e.id = "e" + } + }, + "E" + ) + ]), + html: ` <main> <div id="a">A</div> <div id="b">B</div> @@ -187,16 +287,16 @@ test("reorder keyed", () => <div id="e">E</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "e" }, "E"), - h("div", { key: "a" }, "A"), - h("div", { key: "b" }, "B"), - h("div", { key: "c" }, "C"), - h("div", { key: "d" }, "D") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h("div", { key: "e" }, "E"), + h("div", { key: "a" }, "A"), + h("div", { key: "b" }, "B"), + h("div", { key: "c" }, "C"), + h("div", { key: "d" }, "D") + ]), + html: ` <main> <div id="e">E</div> <div id="a">A</div> @@ -205,16 +305,16 @@ test("reorder keyed", () => <div id="d">D</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "e" }, "E"), - h("div", { key: "d" }, "D"), - h("div", { key: "a" }, "A"), - h("div", { key: "c" }, "C"), - h("div", { key: "b" }, "B") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h("div", { key: "e" }, "E"), + h("div", { key: "d" }, "D"), + h("div", { key: "a" }, "A"), + h("div", { key: "c" }, "C"), + h("div", { key: "b" }, "B") + ]), + html: ` <main> <div id="e">E</div> <div id="d">D</div> @@ -223,16 +323,16 @@ test("reorder keyed", () => <div id="b">B</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "c" }, "C"), - h("div", { key: "e" }, "E"), - h("div", { key: "b" }, "B"), - h("div", { key: "a" }, "A"), - h("div", { key: "d" }, "D") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h("div", { key: "c" }, "C"), + h("div", { key: "e" }, "E"), + h("div", { key: "b" }, "B"), + h("div", { key: "a" }, "A"), + h("div", { key: "d" }, "D") + ]), + html: ` <main> <div id="c">C</div> <div id="e">E</div> @@ -241,20 +341,64 @@ test("reorder keyed", () => <div id="d">D</div> </main> ` - } - ])) + } +]) -test("grow/shrink keyed", () => - TreeTest([ - { - tree: h("main", {}, [ - h("div", { key: "a", oncreate: e => (e.id = "a") }, "A"), - h("div", { key: "b", oncreate: e => (e.id = "b") }, "B"), - h("div", { key: "c", oncreate: e => (e.id = "c") }, "C"), - h("div", { key: "d", oncreate: e => (e.id = "d") }, "D"), - h("div", { key: "e", oncreate: e => (e.id = "e") }, "E") - ]), - html: ` +testTrees("grow/shrink keyed", [ + { + tree: h("main", {}, [ + h( + "div", + { + key: "a", + oncreate(e) { + e.id = "a" + } + }, + "A" + ), + h( + "div", + { + key: "b", + oncreate(e) { + e.id = "b" + } + }, + "B" + ), + h( + "div", + { + key: "c", + oncreate(e) { + e.id = "c" + } + }, + "C" + ), + h( + "div", + { + key: "d", + oncreate(e) { + e.id = "d" + } + }, + "D" + ), + h( + "div", + { + key: "e", + oncreate(e) { + e.id = "e" + } + }, + "E" + ) + ]), + html: ` <main> <div id="a">A</div> <div id="b">B</div> @@ -263,38 +407,74 @@ test("grow/shrink keyed", () => <div id="e">E</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "a" }, "A"), - h("div", { key: "c" }, "C"), - h("div", { key: "d" }, "D") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h("div", { key: "a" }, "A"), + h("div", { key: "c" }, "C"), + h("div", { key: "d" }, "D") + ]), + html: ` <main> <div id="a">A</div> <div id="c">C</div> <div id="d">D</div> </main> ` - }, - { - tree: h("main", {}, [h("div", { key: "d" }, "D")]), - html: ` + }, + { + tree: h("main", {}, [h("div", { key: "d" }, "D")]), + html: ` <main> <div id="d">D</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "a", oncreate: e => (e.id = "a") }, "A"), - h("div", { key: "b", oncreate: e => (e.id = "b") }, "B"), - h("div", { key: "c", oncreate: e => (e.id = "c") }, "C"), - h("div", { key: "d" }, "D"), - h("div", { key: "e", oncreate: e => (e.id = "e") }, "E") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h( + "div", + { + key: "a", + oncreate(e) { + e.id = "a" + } + }, + "A" + ), + h( + "div", + { + key: "b", + oncreate(e) { + e.id = "b" + } + }, + "B" + ), + h( + "div", + { + key: "c", + oncreate(e) { + e.id = "c" + } + }, + "C" + ), + h("div", { key: "d" }, "D"), + h( + "div", + { + key: "e", + oncreate(e) { + e.id = "e" + } + }, + "E" + ) + ]), + html: ` <main> <div id="a">A</div> <div id="b">B</div> @@ -303,15 +483,15 @@ test("grow/shrink keyed", () => <div id="e">E</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "d" }, "D"), - h("div", { key: "c" }, "C"), - h("div", { key: "b" }, "B"), - h("div", { key: "a" }, "A") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h("div", { key: "d" }, "D"), + h("div", { key: "c" }, "C"), + h("div", { key: "b" }, "B"), + h("div", { key: "a" }, "A") + ]), + html: ` <main> <div id="d">D</div> <div id="c">C</div> @@ -319,20 +499,46 @@ test("grow/shrink keyed", () => <div id="a">A</div> </main> ` - } - ])) + } +]) -test("mixed keyed/non-keyed", () => - TreeTest([ - { - tree: h("main", {}, [ - h("div", { key: "a", oncreate: e => (e.id = "a") }, "A"), - h("div", {}, "B"), - h("div", {}, "C"), - h("div", { key: "d", oncreate: e => (e.id = "d") }, "D"), - h("div", { key: "e", oncreate: e => (e.id = "e") }, "E") - ]), - html: ` +testTrees("mixed keyed/non-keyed", [ + { + tree: h("main", {}, [ + h( + "div", + { + key: "a", + oncreate(e) { + e.id = "a" + } + }, + "A" + ), + h("div", {}, "B"), + h("div", {}, "C"), + h( + "div", + { + key: "d", + oncreate(e) { + e.id = "d" + } + }, + "D" + ), + h( + "div", + { + key: "e", + oncreate(e) { + e.id = "e" + } + }, + "E" + ) + ]), + html: ` <main> <div id="a">A</div> <div>B</div> @@ -341,16 +547,16 @@ test("mixed keyed/non-keyed", () => <div id="e">E</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "e" }, "E"), - h("div", {}, "C"), - h("div", {}, "B"), - h("div", { key: "d" }, "D"), - h("div", { key: "a" }, "A") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h("div", { key: "e" }, "E"), + h("div", {}, "C"), + h("div", {}, "B"), + h("div", { key: "d" }, "D"), + h("div", { key: "a" }, "A") + ]), + html: ` <main> <div id="e">E</div> <div>C</div> @@ -359,16 +565,16 @@ test("mixed keyed/non-keyed", () => <div id="a">A</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", {}, "C"), - h("div", { key: "d" }, "D"), - h("div", { key: "a" }, "A"), - h("div", { key: "e" }, "E"), - h("div", {}, "B") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h("div", {}, "C"), + h("div", { key: "d" }, "D"), + h("div", { key: "a" }, "A"), + h("div", { key: "e" }, "E"), + h("div", {}, "B") + ]), + html: ` <main> <div>C</div> <div id="d">D</div> @@ -377,15 +583,15 @@ test("mixed keyed/non-keyed", () => <div>B</div> </main> ` - }, - { - tree: h("main", {}, [ - h("div", { key: "e" }, "E"), - h("div", { key: "d" }, "D"), - h("div", {}, "B"), - h("div", {}, "C") - ]), - html: ` + }, + { + tree: h("main", {}, [ + h("div", { key: "e" }, "E"), + h("div", { key: "d" }, "D"), + h("div", {}, "B"), + h("div", {}, "C") + ]), + html: ` <main> <div id="e">E</div> <div id="d">D</div> @@ -393,37 +599,57 @@ test("mixed keyed/non-keyed", () => <div>C</div> </main> ` - } - ])) + } +]) + +testTrees("styles", [ + { + tree: h("div"), + html: `<div></div>` + }, + { + tree: h("div", { style: { color: "red", fontSize: "1em" } }), + html: `<div style="color: red; font-size: 1em;"></div>` + }, + { + tree: h("div", { style: { color: "blue", float: "left" } }), + html: `<div style="color: blue; float: left;"></div>` + }, + { + tree: h("div"), + html: `<div style=""></div>` + } +]) -test("style", () => - TreeTest([ - { - tree: h("div"), - html: `<div></div>` - }, - { - tree: h("div", { style: { color: "red", fontSize: "1em" } }), - html: `<div style="color: red; font-size: 1em;"></div>` - }, - { - tree: h("div", { style: { color: "blue", float: "left" } }), - html: `<div style="color: blue; float: left;"></div>` - }, - { - tree: h("div"), - html: `<div style=""></div>` - } - ])) +testTrees("update element data", [ + { + tree: h("div", { id: "foo", class: "bar" }), + html: `<div id="foo" class="bar"></div>` + }, + { + tree: h("div", { id: "foo", class: "baz" }), + html: `<div id="foo" class="baz"></div>` + } +]) -test("update element data", () => - TreeTest([ - { - tree: h("div", { id: "foo", class: "bar" }), - html: `<div id="foo" class="bar"></div>` - }, - { - tree: h("div", { id: "foo", class: "baz" }), - html: `<div id="foo" class="baz"></div>` - } - ])) +testTrees("removeAttribute", [ + { + tree: h("div", { id: "foo", class: "bar" }), + html: `<div id="foo" class="bar"></div>` + }, + { + tree: h("div"), + html: `<div></div>` + } +]) + +testTrees("skip setAttribute for functions", [ + { + tree: h("div", { + onclick() { + /**/ + } + }), + html: `<div></div>` + } +])