Skip to content

ArthurClemens/cyano

Repository files navigation

Cyano

Takes a base component and returns a Mithril or React component. This is useful if you want to support both Mithril and React with a minimum of duplicate code.

Online examples

Getting started

Usage of Cyano requires setting an alias setting in your bundler configuration. Read on for details.

Install

With Mithril:

npm install cyano-mithril

With React:

npm install cyano-react

Import

There are 2 methods for importing Cyano in your code. Which one to choose depends on your goal.

Writing applications

When writing an application, it is possible to use identical code for both Mithril and React. This method uses an import from "cyano" which the bundler will resolve to either the Mithril or React version.

See: Import for applications (aliasing "cyano")

Writing libraries

When writing a library, you don't want your library users to burden with a special Cyano setup. Imports need to be resolved after bundling.

This means that Mithril and React components are created in separate files, which pass Cyano methods to shared core component code.

See: Import for libraries (passing Cyano methods)

Usage

Write a single codebase, deploy twice

The basic idea behind Cyano is to make code portable to both Mithril and React.

Perhaps you are a library author who wants to have more users benefit from your solution. You can write once and deploy twice.

Or you are introducing Mithril in a company that is inclined to use React. By supporting both libraries you can hold out a decision until the team has had some more experience.

Cyano lets you create Mithril and React components from shared base components. To make these base components interoperable, they need to understand a shared language.

Shared language

Hooks instead of lifecycle methods

Base components are "functional components" - render functions without lifecycle/class methods.

React Hooks are a replacement for functionality that was previously available only in (React) classes. By replacing class lifecycles with hooks, code becomes more succinct and easier to reason about. Hooks make it trivial to define logic outside of the component and to import parts where needed. Local state and lifecycle methods can be implemented using hooks with little effort.

  • React Hooks have been introduced in React 16.8.
  • For Mithril hooks are implemented with helper library mithril-hooks.

Hyperscript or JSX

Base components can be written in hyperscript or JSX.

If you choose hyperscript: React Without JSX demonstrates how to use React.createElement to write component code. Cyano uses an enhanced version by means of react-hyperscript, which is more lenient in omitting properties and keys.

If you choose JSX, see Configuring JSX how to setup JSX rendering. JSX can be used for both React and Mithril.

Dictionary of HTML attributes

React follows the camelCase convention for "official" HTML attributes, whereas Mithril uses lowercase names. Helper variable a maps the lowercase attribute name to an accepted one.

For example:

a.onclick returns "onclick" for Mithril and "onClick" for React.

Helper functions

Variable Description API doc
cast Takes a base component and returns a Mithril or React component. cast
h The render function for hyperscript. h (render function)
h.trust Function to insert HTML Inserting trusted content
h.fragment Function to wrap elements in a fragment Inserting trusted content
getRef Callback function that gets a reference to the DOM element. getRef
a Dictionary of accepted HTML attributes. a (Accepted HTML attributes)
jsx Babel pragma, import this when writing JSX. jsx

Using hooks

Base components have access to default hooks (see "supported hooks" below) and custom hooks.

import { h, a, useState, useEffect } from "cyano"

const SharedCounter = ({ initialCount }) => {
  const [count, setCount] = useState(initialCount)

  useEffect(
    () => {
      // ...
    },
    [count] // Only re-run when value has changed
  )

  return (
    h("div",
      [
        h(".count",
          { key: "count" },
          count
        ),
        h("button",
          {
            key: "increment",
            [a.onclick]: () => setCount(count + 1),
          },
          "More"
        ),
      ]
    )
  )
}

Supported hooks

  • useState
  • useEffect
  • useLayoutEffect
  • useReducer
  • useRef
  • useMemo
  • useCallback

These React hooks make little sense with Mithril and are not included:

  • useContext
  • useImperativeHandle
  • useDebugValue

Custom hooks

General introduction in React's documentation: Building Your Own Hooks

Using hooks with Mithril: see mithril-hooks for examples.

Importing options

Import for applications (aliasing "cyano")

NOTE: This methods is not recommended for writing libraries. See: Import for libraries (passing Cyano methods) for the alternative approach.

The "aliasing" method uses an import from "cyano" which the bundler will resolve to either the Mithril or React version.

import { cast /*, useState, h, a, etcetera */ } from "cyano"

Our bundler will resolve "cyano" to either cyano-react or cyano-mithril.

Configuring

We need to let the bundler point from "cyano" to cyano-react or cyano-mithril. See instructions for bundlers below:

Code example

Due to our agnostic (aliased) import from "cyano", the component code for React and Mithril is identical.

Hyperscript

import { cast, useState, h, a } from "cyano"

const _Toggle = ({ title }) => {
  const [clicked, setClicked] = useState(false)
  
  return h(".toggle", [
    h("h2", title),
    h("button",
      {
        [a.onclick]: () => setClicked(!clicked)
      },
      "Toggle"
    ),
    h("div",
      clicked ? "On" : "Off"
    )
  ])
}

const Toggle = cast(_Toggle)

// Use:
h(Toggle, { title: "Switch!" })

JSX

The same code written in JSX. See Configuring JSX how to setup JSX rendering.

import { cast, useState, a, jsx } from "cyano"
/* jsx needs to be in scope for JSX to work */

const _Toggle = () => {
  const [clicked, setClicked] = useState(false)

  return (
    <div className="toggle">
      <h2>{title}</h2>
      <div
        className={`button ${clicked ? "is-info" : ""}`}
        {...{
          [a.onclick]: () => setClicked(!clicked)
        }}>
        Toggle
      </div>
      <div className="info">
        {clicked ? "On" : "Off"}
      </div>
    </div>
  )
}

const Toggle = cast(_Toggle)

// Use:
<Toggle title="Switch!" />

Note the HTML attribute "onclick" that must be passed in the properties object, because we can't use dynamic keys for JSX attributes.

Passing or nesting components

Example: a Navigation component that contains Link components.

Either convert the Link before using:

import { cast, h, a } from "cyano"

const _Link = () => {
  // ...
}
const Link = cast(_Link)

const _Navigation = () => [
  h(Link, { label: "Home",    path: "/"} ),
  h(Link, { label: "Contact", path: "/contact"} ),
]
const Navigation = cast(_Navigation)

Or pass the converted Link as initial parameter to Navigation:

import { cast, h, a } from "cyano"

const _Link = () => {
  // ...
}

const _Navigation = ({ Link }) => [
  h(Link, { label: "Home",    path: "/"} ),
  h(Link, { label: "Contact", path: "/contact"} ),
]

const Link = cast(_Link)
const Navigation = cast(_Navigation, { Link })

Import for libraries (passing Cyano methods)

Mithril and React components are created in separate files, which pass Cyano methods to shared core component code.

// react-button

import { _Button } from "core-button"
import { Icon } from "react-icon"
import { cast, h, a, useState, useEffect, useRef, getRef } from "cyano-react"

export const Button = cast(_Button, { h, a, getRef, useState, useEffect, useRef, Icon })
// core-button

export const _Button = ({ h, a, getRef, useState, useEffect, useRef, Icon, ...props }) => {
  // etcetera
}

Solving issues

Issues with keys

This case may trip you up:

[
  h("div",
    { key: 1 }
  ),
  [2,3,4].map(n => // Error when Mithril runs this code
    h("div",
      { key: n },
      n
    )
  )
]

Mithril will throw an error because not all children in the array have a key.

The reason is that React and Mithril handle arrays differently: React flattens inner arrays, but Mithril doesn't.

This code ends up processed this way for each:

[
  h("div", { key: 1 }),
  [
    h("div", { key: 2 })
  ]
]

In React:

// Everything's flattened, so everything's keyed as expected
[
  h("div", { key: 1 }),
  h("div", { key: 2 }),
]

In Mithril:

h.fragment([
  h("div", { key: 1 }),
  h.fragment([ // Lacks a key, so an error is thrown
    h("div", { key: 2 }),
  ]),
])

To solve this in a cross-platform way, rewrite the tripping-up code with a keyed wrapper fragment:

[
  h("div",
    { key: 1 }
  ),
  h.fragment(
    { key: "numbers" },
    [2,3,4].map(n =>
      h("div",
        { key: n },
        n
      )
    )
  )
]

API

cast

Takes a base component and returns a Mithril or React component.

Signature

cast(renderFunction, initialProps?) => Component

Argument Type Required Description
renderFunction Function component Yes The base/common/shared functional component to be converted for Mithril or React
initialProps Object No Any variable to pass to renderFunction; see also Passing or nesting components
Returns Component

The returned Component can be called as any component:

h(Component, {
  // component props
})

The component render function that is called will receive a combined object of initialProps and component props, plus property children:

const _Component = ({ defaultTitle, title, children )} => {
  // ...
}

const Component = cast(_Component, { defaultTitle: "a blue sky" })

h(Component,
  { title: "casting a shadow" },
  h("div", "Cloud child")  
)

h (render function)

The render function for hyperscript.

Signature

h(selector, properties?, children?) => Element

Argument Type Required Description
selector `String Object` Yes
properties Object No HTML attributes or element properties
children `Array<Vnode ReactElement> String
Returns Vnode (for Mithril); ReactElement for React

Inserting trusted content

Mithril's API contains m.trust that "turns an HTML or SVG string into unescaped HTML or SVG". The documentation continues with the warning

Do not use m.trust on unsanitized user input. Always try to use an alternative method first, before considering using m.trust.

With this caveat, it is sometimes useful to insert a piece of HTML or SVG into a container.

The render function h is enhanced with method trust to do that:

const iconBack = h.trust("<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path d=\"M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z\"/></svg>")

cyano-react uses dangerouslySetInnerHTML to set trusted content with a "div" as wrapper element. The element tag can be set with the second parameter.

For consistency, cyano-mithril function h.trust is enhanced with this second parameter too.

Signature

h.trust(html, wrapper) => Element

Argument Type Required Default Description
html String Yes A string containing HTML or SVG text.
wrapper Element tag name No cyano-react: "div"; cyano-mithril: undefined Wrapper element
Returns Vnode (for Mithril); ReactElement for React

Wrapping fragments

Both Mithril and React have a concept called "fragments" to group children in a list:

There are key differences between both libraries:

  • React's Fragment only supports property key, but does some smart handling of inserting extra HTML elements (see the doc example for table rows)
  • Mithrils m.fragment supports lifecycle methods in properties, but does not do any magic with HTML elements

Cyano's bridge function h.fragment is oblivious to either library, so you could make use of either implementation, but for cross-platform code you'd need to stick to property key and make no assumptions on handling HTML elements around the fragment.

Signature

h.fragment(properties?, children) => Element

Argument Type Required Default Description
properties object No For cross-library code: only use key
children `Array<Vnode ReactElement> String Number
Returns Vnode (for Mithril); ReactElement for React

a (HTML attributes)

Dictionary of accepted HTML attributes.

a maps the lowercase attribute name to an accepted one. Instead of onClick (for React) or (onclick (for Mithril) you write:

[a.onclick]: () => setClicked(!clicked)

or in JSX:

{...{
  [a.onclick]: () => setClicked(!clicked)
}}

Complete list of included html attributes:

getRef

Callback function getRef gets a reference to the DOM element.

Signature

getRef(fn)

Argument Type Required Description
fn Function Yes Function that receives the DOM reference.
Returns function ref(dom) { /* use dom */}

getRef accepts a function that receives the DOM reference.

DOM reference are commonly stored in a useRef object, with property current to store the data. Note that setting or updating useRef will not cause a re-render.

import { useRef, h, a, getRef } from "cyano"

const _Component = () => {
  const domRef = useRef()
  
  return (
    h("div", 
      {
        ...getRef(dom => {
          // React will call this each render
          domRef.current = domRef.current || dom; 
        }) 
      },
      "My component"
    )
  )
}

The example above contains a check to prevent superfluous updates to the variable domRef.

Forward refs

When getRef is passed to a component, the component props will contain the function ref. This can be used from the parent:

// Input.js

h("input",
  {
    ...getRef((dom: Element) => {
      if (dom && !domElement) {
        setDomElement(dom);
        if (props.ref) {
          // pass back to parent
          props.ref(dom);
        }
      }
    }),
  }
)
 
// parent that uses Input
h(Input, {
  ref: dom => (
    setDomElement(dom)
  )
})

Under the hood

  • cyano-mithril:
    • getRef uses Mithril's lifecycle method oncreate to access the DOM element. This method will be called once.
  • cyano-react:
    • getRef uses React's ref method to access the DOM element. This method will be called on each render. When the element is removed from the DOM, getRef will receive null. Using an update check as shown above will prevent that the reference will be lost.

Children

Child elements are accessed through the component prop children:

const _Component = ({ children }) => {
  return [
    h("h2", "My title"),
    children
  ]
}

jsx

Babel pragma. Only import this when writing JSX.

Additional setup when aliasing "cyano"

NOTE: This setup is only needed when using identical code for both Mithril and React. This can be used in applications but is not recommended for writing libraries.

Bundler configuration

We need to let the bundler point from "cyano" to cyano-react or cyano-mithril.

Each bundler has a different method to to this - it is generally called "map" or "alias".

Environment variable setup

The examples below show how to direct from "cyano" to "cyano-mithril". When you are using the same bundler scripts for both Mithril and React, you should consider to configure the alias, for instance by using an environment variable.

Example (using Webpack):

Configuring Webpack

// webpack.config.js

const path = require("path");
const baseDir = process.cwd();

Then add to the configuration:

resolve: {
  alias: {
    "cyano": path.resolve(baseDir, "node_modules/cyano-mithril"),
  }
}

Configuring Rollup

Use rollup-plugin-pathmodify:

// rollup.config.js

import path from "path";
import pathmodify from "rollup-plugin-pathmodify";

const baseDir = process.cwd();

Then add to plugins:

pathmodify({
  aliases: [
    {
      id: "cyano",
      resolveTo: path.resolve(baseDir, "node_modules/cyano-mithril/dist/cyano-mithril.module.js"),
    },
  ]
})

Configuring Browserify

Use the pathmodify plugin to change the default config path to your custom file:

const path = require("path");
const pathmodify = require('pathmodify');

const baseDir = process.cwd();

Then add to browserify():

.plugin(pathmodify, {
  mods: [
    pathmodify.mod.id("cyano", path.resolve(baseDir, "node_modules/cyano-mithril")),
  ]
})
// ...

Configuring JSX

This is an optional step.

Install @babel/plugin-transform-react-jsx and add to the plugins in your Babel configuration:

["@babel/plugin-transform-react-jsx", {
  "pragma": "jsx"
}]

jsx is a variable exported by Cyano. This needs to be in scope when using JSX in component code (but does not need to be called explicitly):

import { jsx } from "cyano"

React and Webpack

It may be necessary to point Webpack to the React module. Add an entry to resolve.alias in the config:

resolve: {
  alias: {
    "react": path.resolve(baseDir, "node_modules/react"),
  },
},

Compatibility

  • Mithril: tested with Mithril 1.1.6 and Mithril 2.x
  • React: tested with React 16.8.4 and higher

Sizes

cyano-mithril

Includes mithril-hooks

┌───────────────────────────────────────────┐
│                                           │
│   Bundle Name:  cyano-mithril.module.js   │
│   Bundle Size:  3.72 KB                   │
│   Minified Size:  2.93 KB                 │
│   Gzipped Size:  1.14 KB                  │
│                                           │
└───────────────────────────────────────────┘

┌────────────────────────────────────────┐
│                                        │
│   Bundle Name:  cyano-mithril.umd.js   │
│   Bundle Size:  5.15 KB                │
│   Minified Size:  3.56 KB              │
│   Gzipped Size:  1.35 KB               │
│                                        │
└────────────────────────────────────────┘

┌─────────────────────────────────────┐
│                                     │
│   Bundle Name:  cyano-mithril.cjs   │
│   Bundle Size:  4.4 KB              │
│   Minified Size:  3.53 KB           │
│   Gzipped Size:  1.26 KB            │
│                                     │
└─────────────────────────────────────┘

cyano-react

Includes react-hyperscript

┌─────────────────────────────────────────┐
│                                         │
│   Bundle Name:  cyano-react.module.js   │
│   Bundle Size:  6.28 KB                 │
│   Minified Size:  4.27 KB               │
│   Gzipped Size:  1.92 KB                │
│                                         │
└─────────────────────────────────────────┘

┌──────────────────────────────────────┐
│                                      │
│   Bundle Name:  cyano-react.umd.js   │
│   Bundle Size:  7.83 KB              │
│   Minified Size:  4.65 KB            │
│   Gzipped Size:  2.03 KB             │
│                                      │
└──────────────────────────────────────┘

┌───────────────────────────────────┐
│                                   │
│   Bundle Name:  cyano-react.cjs   │
│   Bundle Size:  6.93 KB           │
│   Minified Size:  4.85 KB         │
│   Gzipped Size:  2.03 KB          │
│                                   │
└───────────────────────────────────┘

License

MIT