Skip to content

mitranim/prax

Repository files navigation

Overview

HTML/DOM rendering system for hybrid SSR + SPA apps. See #Why. In short: performance and radical simplicity.

  • Markup = nested function calls.
  • No intermediate representations.
    • Render directly to strings in Deno/Node.
    • Render directly to DOM nodes in browsers.
  • No VDOM.
  • No diffing (mostly).
  • No library classes.
  • No templates.
  • No string parsing.
  • Render only once. Use native custom elements for state. (Custom elements don't need library support.)
  • Replace instead of reflowing.
  • Runs in Node, Deno, and browsers.
  • Nice-to-use in plain JS. No build system required.

Tiny (a few kilobytes unminified) and dependency-free. Native JS modules.

TOC

Why

Why not React?

Bad for SSR+SPA

React seems particularly unfit for hybrid SSR + SPA apps. Your ideal flow:

  • On any request, render the entire page in Deno/Node.
  • The user gets the fully-built content. It has no JS placeholders, doesn't require any ajax, and doesn't contain invisible JSON.
  • Load the same JS into the browser. Enable interactive components and pushstate links. No initial ajax.
  • On pushstate transitions, fetch data and render pages entirely in the browser. The rendering code is fully isomorphic between Deno/Node and browsers.

If the entire page was rendered with React, then to activate the interactive parts, you must load all that JS and re-render the entire page with React. In the browser. On a page that was already rendered. This is ludicrously wasteful. In my current company's app, this easily takes 500ms of CPU time on many pages (using Preact). To make it worse, because markup is a function of data (regardless of your rendering library), this requires the original data, which you must either invisibly inline into HTML (wasted traffic, SEO deranking), or re-fetch in the browser (wasted traffic, server load). This is insane.

For apps with SSR, a better approach is to activate interactive components in-place without any "render". Native custom elements are particularly fit for this. This requires separating the markup code from the stateful behavior code. React is predicated on the idea of co-locating markup and state. You could still render your markup with React, but when markup is stateless, this is pointlessly inefficient. Prax can do it much faster, while optionally using JSX.

Slow

React's rendering model is inefficient by design. Svelte's creator did a better job explaining this: https://svelte.dev/blog/virtual-dom-is-pure-overhead.

The design inefficiencies described above shouldn't apply to the initial page render in Deno/Node, when there's nothing to diff against. However, there are also needless implementation inefficiencies. My benchmarks are for Preact, which we've been using over React for size reasons. At my current company, replacing preact@10.5.13 and preact-render-to-string@5.1.18 with the initial, naive and almost unoptimized Prax yields x5 better performance in Node, and somewhere between x3 and x8 (depending on how you measure) better in browsers, for rendering big pages. In addition, switching to custom elements for stateful components allows to completely eliminate the in-browser initial render, which would hitch the CPU for 500ms on some pages, while also eliminating the need to fetch or inline a huge amount of data that was required for that render.

Parts of the overhead weren't in Preact itself, but merely encouraged by it. In SSR, components would initialize state, create additional owned objects, bind callbacks, and establish implicit reactive subscriptions, even though this would all be wasted. This doesn't excuse an unfit model, and the performance improvement is real.

Large

By now React has bloated to what, 100+ KiB minified? More? Fortunately, Preact solves that (≈10 KiB minified at the time of writing). Prax is even smaller; a few kilobytes unminified, with no dependencies.

Why not Svelte?

Svelte has similar design goals, but seems to require a build system, which automatically invalidates it for me. With native module support in browsers, you can run from source. Don't squander this.

Prax targets a particular breed of SSR+SPA apps, for which Svelte might be unfit. I haven't checked Svelte's server-rendering story. See the constraints outlined above.

Why not plain strings?

For the application architecture espoused by Prax, it would be even simpler and faster to use strings, so why bother with Prax?

`<div class="${cls}">${content}</div>`
  • Prax provides an isomorphic API that renders to strings in Deno/Node, and to DOM nodes in browsers.
  • Prax handles a myriad of HTML/XML gotchas, such as content escaping, nil tolerance, and various bug prevention measures.
  • Prax is JSX-compatible, without any React gunk.
  • Nested function calls are more syntactically precise/rich/powerful than a large string. Editors provide better support. Syntax errors are immediately found.
  • Probably more.

Why not framework X?

Probably never heard of X! For the very specific requirements outlined above, it was faster to build a fit, than to search among the thousands of unfits. If one already existed, let me know.

Usage

With NPM:

npm i -E prax

With URL imports in Deno:

import {E} from 'https://cdn.jsdelivr.net/npm/prax@0.8.0/str.mjs'

With URL imports in browsers:

import {E} from 'https://cdn.jsdelivr.net/npm/prax@0.8.0/dom.mjs'

This example uses plain JS. Prax is also #compatible with JSX. For a better experience, use native modules and run your app from source in both Deno/Node and browsers.

import {E, doc} from 'prax'

function main() {
  const html = Html({title: 'home', body: Index()})
  console.log(html)
}

function Html({title, body}) {
  return doc(
    E('html', {lang: 'en'},
      E('head', {},
        E('link', {rel: 'stylesheet', href: '/styles/main.css'}),
        !title ? null : E('title', {}, title),
      ),
      E('body', {},
        body,
        E('script', {type: 'module', src: '/scripts/browser.mjs'}),
      ),
    )
  )
}

function Index() {
  return E('div', {class: 'some-class'}, `Hello world!`)
}

JSX example. See #notes on JSX compatibility.

/* @jsx E */

import {E, doc} from 'prax'

function main() {
  const html = Html({title: 'home', body: Index()})

  // In `str.mjs`, this is a string.
  // In `dom.mjs`, this is a DOM tree.
  console.log(html)
}

function Html({title, body}) {
  return doc(
    <html lang='en'>
      <head>
        <link rel='stylesheet' href='/styles/main.css' />
        {!title ? null : <title>{title}</title>}
      </head>
      <body>
        {body}
        <script type='module' src='/scripts/browser.mjs' />
      </body>
    </html>
  )
}

function Index() {
  return (
    <div class='some-class'>Hello world!</div>
  )
}

API

Prax provides #dom.mjs for browsers and #str.mjs for Deno/Node. The rendering functions such as #E are somewhat isomorphic between these modules: you call them the same way, but the output is different, tailored to the environment. Its package.json specifies which file should be imported where, in ways understood by Node and bundlers such as Esbuild. You should be able to just:

import {E} from 'prax'
import * as x from 'prax'

Also, you don't need a bundler! JS modules are natively supported by evergreen browsers. To avoid repeating the import URL, either list your dependencies in one module imported by the rest of the app, or use an importmap. Importmap support is polyfillable. You can also use a bundler such as Esbuild just for production builds.

<script type="importmap">
  {"imports": {"prax": "/node_modules/prax/dom.mjs"}}
</script>

When using Deno, your importmap should choose #str.mjs:

{"imports": {"prax": "/node_modules/prax/str.mjs"}}

Isomorphic

The following APIs are provided by both #dom.mjs and #str.mjs.

E(type, props, ...children)

Short for "element", abbreviated for frequent use. Renders an HTML element. In #str.mjs returns a string, as Raw to indicate that it shouldn't be escaped. In #dom.mjs returns a DOM node.

type must be a string. See #Props for props rules, and #Children for child rules.

const node = E('div', {class: 'one'}, 'two')
console.log(node)

// `str.mjs`: [String (Raw): '<div class="one">two</div>']
// `dom.mjs`: <div class="one">two</div>

In browsers, props is passed to document.createElement as-is, in order to support creation of customized built-in elements via is. Also see #Direct Instantiation for additional thoughts on this.

class Btn extends HTMLButtonElement {}
customElements.define('a-btn', Btn, {extends: 'button'})

import {E} from 'prax'

// Works in Deno/Node and browsers. Creates a `Btn` in browsers.
E('button', {is: 'a-btn'})

// Equivalent to the above, but works only in browsers.
new Btn()

S(type, props, ...children)

Exactly like #E, but generates SVG markup. In Deno/Node, either function will work, but in browsers, you must use S for SVG. It uses document.createElementNS with the SVG namespace.

This is because unlike every template-based system, including React, Prax renders immediately. Nested function calls are evaluated inner-to-outer. When rendering an arbitrary element like path (there are many!), E has no way of knowing that it will eventually be included into svg. HTML parsers automate this because they parse outer elements first.

import {S} from 'prax'

function SomeIcon() {
  return S('svg', {class: 'my-icon'}, S('path', {}, '...'))
}

F(...children)

Short for "fragment". Renders the children without an enclosing element. In #str.mjs, this simply combines their markup without any wrappers or delimiters, and returns a string as Raw. In #dom.mjs, this returns a DocumentFragment.

You will rarely use this, because #E supports arrays of children, nested to any depth. F is used internally by #reset.

cls(...vals)

Combines multiple CSS classes:

  • Ignores falsy values (nil, '', false, 0, NaN).
  • Recursively traverses arrays.
  • Combines strings, space-separated.
x.cls('one', ['two'], false, 0, null, [['three']])
// 'one two three'

len(children)

Analog of React.Children.count. Counts non-nil children, recursively traversing arrays.

const children = ['one', null, [['two'], null]]
x.len(children)
// 2

vac(children)

The name is short for "vacate" / "vacuum" / "vacuous". Same as len(children) ? children : undefined, but more efficient.

x.vac(null)
// undefined

x.vac([[[null]]])
// undefined

x.vac([null, 0, 'str'])
// [null, 0, 'str']

This function allows to use && without accidentally rendering a falsy value such as false or 0. Without x.vac, && may evaluate to something not intended for rendering.

x.vac(someValue) && E(`div`, {}, someValue)

map(children, fun, ...args)

where fun is ƒ(child, i, ...args)

Analog of React.Children.map. Flatmaps children via fun, returning the resulting array. Ignores nils and recursively traverses arrays.

const children = ['one', null, [['two'], null]]
function fun(...args) {return args}
x.map(children, fun, 'bonus')
// [['one', 0, 'bonus'], ['two', 1, 'bonus']]

doc(val)

Shortcut for prepending <!doctype html>.

  • In #str.mjs, this encodes val using the #children rules, prepends doctype, and returns a plain string, which may be served over HTTP, written to a file, etc.
  • In #dom.mjs, this simply returns val. Provided for isomorphism.
import {E, doc} from 'prax'

function onRequest(req, res) {
  res.end(Html())
}

function Html() {
  return doc(
    E('html', {},
      E('head'),
      E('body'),
    )
  )
}

merge(...vals)

Combines multiple #props into one, merging their attributes, dataset, style, class, className whenever possible. For other properties, this performs an override rather than merge (last value wins). In case of style, merging is done only for style dicts, not for style strings.

import * as x from 'prax'

x.merge({class: `one`, onclick: someFunc}, {class: `two`, disabled: true})
// {class: `one two`, onclick: someFunc, disabled: true}

lax(val)

Toggles lax/strict mode, which affects Prax's #stringification rules. This is a combined getter/setter:

import * as x from 'prax'

x.lax()      // false
x.lax(true)  // true
x.lax()      // true
x.lax(false) // false
x.lax()      // false

e(type, props, ...children)

(Better name pending.) Tiny shortcut for making shortcuts. Performs partial application of #E with the given arguments.

export const a    = e('a')
export const div  = e('div')
export const bold = e('strong', {class: 'weight-bold'})

function Page() {
  return div({},
    a({href: '/'}, bold(`Home`)),
  )
}

dom.mjs

dom.mjs should be used in browsers. Its rendering functions such as #E return actual DOM nodes. It also provides shortcuts for DOM mutation such as #reset for individual elements and #resetDoc for the entire document.

reset(elem, props, ...children)

Mutates the element, resetting it to the given props via #resetProps and replacing its children; see #children rules.

reset carefully avoids destroying existing content on render exceptions. It buffers children in a temporary DocumentFragment, replacing the previous children only when fully built.

import * as x from 'prax'

class Btn extends HTMLButtonElement {
  constructor() {
    super()
    this.onclick = this.onClick
  }

  onClick() {
    x.reset(this, {class: 'activated', disabled: true}, `clicked!`)
  }
}
customElements.define('a-btn', Btn, {extends: 'button'})

resetProps(elem, props)

Mutates the element, resetting its properties and attributes. Properties and attributes missing from props are not affected. To unset existing props, include them with the appropriate "zero value" (usually null/undefined).

Avoids reassigning properties when values are identical via Object.is. Many DOM properties are setters with side effects. This avoids unexpected costs.

import * as x from 'prax'

x.resetProps(elem, {class: 'new-class', hidden: false})

replace(node, ...children)

Shortcut for:

import * as x from 'prax'

node.parentNode.replaceChild(x.F(...children), node)

resetDoc(head, body)

Carefully updates the current document.head and document.body. Shortcut for using #resetHead and #resetBody together. Example:

import * as x from 'prax'
import {E} from 'prax'

function SomePage() {
  x.resetDoc(
    E(`head`, {},
      E(`title`, {}, `some title`),
      E(`meta`, {name: `author`, content: `some author`}),
      E(`meta`, {name: `description`, content: `some description`}),
    ),
    E(`body`, {},
      E(`p`, {}, `hello world!`),
    )
  )
}

resetHead(head)

Takes HTMLHeadElement, usually rendered with E('head', ...), and carefully updates the current document.head. Rules:

  • Doesn't affect nodes that weren't previously passed to resetHead.
  • Instead of appending <title>, sets document.title to its text content.

Nodes previously passed to resetHead are tagged using a WeakSet which is exported under the name metas but undocumented. Adding other nodes to this set will cause Prax to replace them as well.

See #resetDoc for examples.

resetBody(body)

Takes HTMLBodyElement, usually rendered with E('body', ...), and replaces the current document.body, preserving focus if possible. See #resetDoc for examples.

resetText(node, src)

Takes an Element and replaces its textContent by a stringified version of src, using Prax's #stringification rules. Returns the given element. Very similar to the following:

node.textContent = src

... but will not render [object Object] or other similar garbage. See #rules.

props(node)

Takes an Element and returns very approximate source props derived only from attributes.

x.props(E('div', {class: 'one', dataset: {two: 'three'}}))
// {dataset: DOMStringMap{two: "three"}, class: "one"}

str.mjs

str.mjs should be used in Deno/Node. Its rendering functions such as #E return strings, which can be sent to clients or written to disk.

It also exports some functions related to HTML encoding which are undocumented. Check the source if interested.

reg.mjs

reg.mjs provides shortcuts for registering custom DOM elements. It can be imported in Deno/Node, but unlike the other modules, it's not meant for hybrid SSR/SPA. In hybrid rendering, custom element tags must be hardcoded, which requires extra code and is annoying without access to decorators. reg.mjs completely automates element registration by deriving tag names from class names, but should only be used in SPA that instantiate those classes with new.

Base Classes

#reg.mjs exports various base classes: HTMLElement, HTMLAnchorElement, and so on. All of them implement automatic registration on new, which also works for subclasses. When possible, they extend the corresponding native DOM classes, falling back on HTMLElement, falling back on Object.

Subclassing these has two benefits:

  • Automatic registration.
  • Compatibility with Deno/Node.
    • Without the DOM, modules can be executed/imported, but classes are nops.

reg(cls)

Short for "register". Registers a custom DOM element class. Automatically derives tag name from class name. Inspects the prototype chain to automatically determine the base tag for the {extends} option. Automatically avoids naming conflicts, but the resulting tag names are non-deterministic and should not be relied upon.

This function is idempotent: repeated calls with the same class are nops. This is handy for new.target, see below. All of the following examples are nearly equivalent.

import * as r from 'prax/reg.mjs'

// Recommended approach. Automatically registers on `new`.
{
  class Link extends r.HTMLAnchorElement {}
}

{
  class Link extends HTMLAnchorElement {}
  r.reg(Link)
}

{
  @r.reg
  class Link extends HTMLAnchorElement {}
}

{
  class Link extends HTMLAnchorElement {
    constructor() {
      r.reg(new.target)
      super()
    }
  }
}

// Built-in approach. Annoying to use.
{
  class Link extends HTMLAnchorElement {}
  customElements.define(`a-link`, Link, {extends: `a`})
}

rcompat.mjs

rcompat.mjs exports a few functions for JSX compatibility and migrating code from React. See the section #JSX for usage examples. Important exports:

  • R
  • F (different one!)

Undocumented

Some tools are exported but undocumented to avoid bloating the docs. The source code should be self-explanatory.

Imperative, Synchronous

Imperative control flow and immediate, synchronous side effects are precious things. Don't squander them carelessly.

In Prax, everything is immediate. Rendering exceptions can be caught via try/catch. Magic context can be setup trivially in user code, via try/finally, without library support. Lifecycle stages such as "before render" and "after DOM mounting" can be done just by placing lines of code before and after a #reset call. (Also via native lifecycle callbacks in custom elements, which doesn't require library support.)

Compare the hacks and workarounds invented in React to implement the same trivial things.

Direct Instantiation

Unlike most "modern" rendering libraries, Prax doesn't stand between you and DOM elements. Functions such as #E are trivial shortcuts for document.createElement. This has nice knock-on effects for custom elements.

Isomorphic code that runs in Deno/Node and browsers must use E, because on the server you always render to a string. However, code that runs only in the browser is free to use direct instantiation, with custom constructor signatures.

The following example is a trivial custom element that takes an observable object and displays one of its properties as text. Subscription and unsubscription is automatic. (Observable signature approximated from Espo → isObs.)

You can simply new RecText(obs), passing a specific observable. No "props" involved in this. No weird library gotchas to deal with. When using TS or Flow, signatures can be properly typed, without hacks and workarounds such as "prop types".

import {E, reset} from 'prax'

function SomeMarkup() {
  return new RecText(someObservable, {class: 'text'})
}

class RecText extends HTMLElement {
  constructor(observable, props) {
    super()
    this.obs = observable
    x.resetProps(this, props)
  }

  connectedCallback() {
    this.obs.sub(this)
    this.trig()
  }

  disconnectedCallback() {
    this.obs.unsub(this)
  }

  trig() {
    this.textContent = this.obs.val
  }
}
customElements.define('a-rec-text', RecText)

Props

Just like React, Prax conflates attributes and properties, calling everything "props". Here are the rules.

  • The term nil stands for both null and undefined.
  • Props as a whole are nil | {}.
  • Any prop with a nil value is either unset or skipped, as appropriate.
  • class and className are both supported, as nil | string.
  • attributes is nil | {}. Every key-value is assumed to be an attribute, even in browsers, and follows the normal attribute assignment rules; see below.
  • style is nil | string | {}. If {}, it must have camelCase keys, matching the structure of a CSSStyleDeclaration object. Values are nil | string.
  • dataset must be nil | {}, where keys are camelCase without the data- prefix. Values follow the attribute encoding rules. In #str.mjs, dataset is converted to data-* attributes. You can also just use those attributes.
  • innerHTML works in both environments, and must be nil | string.
  • for and htmlFor both work, and must be nil | string.
  • ARIA properties such as ariaCurrent work in both environments, and must be nil | string. In #str.mjs, they're converted to kebab-cased aria-* attributes. You can use both property names and attribute names.
  • The value of any attribute, or a DOM property whose type is known to be string, must be either nil or #stringable.

Additional environment-specific rules:

  • In #str.mjs, everything non-special-cased is assumed to be an attribute, and must be nil or #stringable.
  • In #dom.mjs, there's a heuristic for deciding whether to assign a property or attribute. Prax will try to default to properties, but use attributes as a fallback for properties that are completely unknown or whose value doesn't match the type expected by the DOM.

Unlike React, Prax has no made-up properties or weird renamings. Use autocomplete rather than autoComplete, oninput rather than onChange, and so on.

Children

All rendering functions, such as #E or #reset, take ...children as rest parameters and follow the same rules:

  • Nil (null or undefined) is ignored.
  • '' is also ignored (doesn't create a Text node).
  • [] is traversed, recursively if needed. The following are all equivalent: a, b, c | [a, b, c] | [[a], [[[b, [[c]]]]]].
  • As a consequence of the previous rules, [null, [undefined]] is the same as no children at all.
  • Other primitives (numbers, bools, symbols) are stringified.
  • new Raw strings are considered "inner HTML". In #str.mjs, their content is included verbatim and not escaped. In #dom.mjs, their content is parsed into DOM nodes, similarly to innerHTML.
  • Anything else must be a #stringable object.

In #str.mjs, after resolving all these rules, the output string is escaped, following standard rules. The only exception is new Raw strings, which are verbatim.

Caution: literal content of script elements may require additional escaping when it contains </script> inside strings, regexps, and so on. The following example generates broken markup, and will display a visible '). Prax currently doesn't escape this automatically.

E('script', {}, new Raw(`console.log('</script>')`))
<script>console.log('</script>')</script>

Stringable

Prax's stringification rules are carefully designed to minimize gotchas and bugs.

  • null and undefined are equivalent to ''.
  • Other primitives are stringified. (For example, 0'0', false'false', NaN'NaN'.)
  • Objects without a custom .toString method are verboten. This includes {} and a variety of other classes. This is a bug prevention measure: the vast majority of such objects are never intended for rendering, and are only passed accidentally.
    • When lax(false) (default, recommended for development), non-stringable objects cause exceptions.
    • When lax(true) (recommended for production), non-stringable objects are treated as nil.
    • See the #lax function.
  • Other objects are stringified via their .toString. For example, rendering a Date or URL object is OK.

JSX

Prax comes with an optional adapter for JSX compatibility and migrating React-based code. Requires a bit of wiring-up. Make a file with the following:

import * as x from 'prax'
import {R} from 'prax/rcompat.mjs'

export {F} from 'prax/rcompat.mjs'
export function E(...args) {return R(x.E, ...args)}
export function S(...args) {return R(x.S, ...args)}

Configure your transpiler (Babel / Typescript / etc.) to use the "legacy" JSX transform, calling the resulting E for normal elements and F for fragments.

Unlike React, Prax can't use the same function for normal and SVG elements. Put all your SVG into a separate file, with a JSX pragma to use this special S function for that file.

Afterwards, the following should work:

function Outer() {
  return <Inner class='one'>two</Inner>
}

function Inner({children, ...props}) {
  return <div {...props}>one {children} two</div>
}

License

https://unlicense.org

Misc

I'm receptive to suggestions. If this library almost satisfies you but needs changes, open an issue or chat me up. Contacts: https://mitranim.com/#contacts

About

Experimental rendering library geared towards hybrid SSR+SPA apps. Focus on radical simplicity and performance. Tiny and dependency-free.

Topics

Resources

Stars

Watchers

Forks

Contributors 4

  •  
  •  
  •  
  •