Skip to content

71/ricochet

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

42 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ricochet

A small and unopinionated web framework meant to be simple, fast and lightweight.

Overview

Ricochet is a small web framework which uses the JSX syntax and observable sequences to define layouts. Its main ability is to render NestedNodes into the DOM efficiently, where a NestedNode is defined as follows:

// A nested node can be...
type NestedNode =
  // ... a DOM element,
  | Element
  // ... a primitive,
  | number | string
  // ... a list of nested nodes,
  | ReadonlyArray<NestedNode>
  // ... or an observable sequence of nested nodes.
  | Observable<NestedNode>

Therefore, Ricochet can render any array, observable sequence or node into the DOM, and make sure the rendered node stays in sync with the value that was rendered, without rendering the node multiple times.

Ricochet does not optimize its operations by default, but makes it extremely easy to add features such as memoization, update batching and efficient list rendering.

Getting started

Ricochet components are only rendered once (when they are instantiated), and updated when a reactive stream they depend on changes.

For instance, we can compare a clock in React and Ricochet.

const Clock = () => {
  // State is introduced explicitely using 'useState'.
  //
  // The 'Clock' function will be called several times per second,
  // but 'date' will be persisted thanks to 'useState'.
  const [date, setDate] = React.useState(new Date())

  // Effects are introduced using 'useEffect'.
  React.useEffect(() => {
    const interval = setInterval(() => setDate(new Date()), 1000)

    return () => {
      clearInterval(interval)
    }
  })

  return (
    <span>{date.toString()}</span>
  )
}

Now here it is in Ricochet. Please note that subject (defined in ricochet/reactive), creates an observable similar to a BehaviorSubject.

const Clock = () => {
  // Since 'Clock' is only called once, there is no need for a
  // tool such as 'useState'.
  const date$ = subject(new Date())

  // Once again, there is no need to wrap this logic at all.
  const interval = setInterval(() => date$.next(new Date()), 1000)

  // Resources are bound to elements and can be disposed of by
  // using `element.destroy()`. To give ownership of a subscription
  // to an element, we can use the 'attach' function.
  attach({
    unsubscribe() {
      clearInterval(interval)
    }
  })

  return (
    // Ricochet has first class support for streams. When 'date'
    // will be updated, the content of the span will be as well.
    <span>{date$}</span>
  )
}

Event handlers and data binding

Two different ways are provided to subscribe to event handlers on rendered elements.

The first way is to simply set an element's on* property on creation, ie.

const onClickHandler = () => {
  alert('Clicked!')
}

<button onclick={onClickHandler} />

The second way, unique to Ricochet, is to create a Connectable, which can be attached to an element during its creation. This provides an easy way to convert an event handler into an observable sequence, while playing nicely with the declarative JSX syntax.

const click$ = eventListener('click')

attach(
  click$.subscribe(() => {
    alert('Clicked!')
  })
)

<button connect={click$} />

Another Connectable is provided for data binding.

const name$ = subject('John')
const nameBinding$ = valueBinder(name$)

// Any change to 'name$' will update the value of the input, and any
// change to the input's value will update 'name$'.
<input type='text' connect={nameBinding$} />

Performances

Ricochet's goal is to avoid using a VDOM, and instead to create redraws, virtual or real, as rarely as possible. Since only the parts of the DOM that depend on a stream will be recomputed when the stream changes, allocations should be less common, and changes to the DOM should be as rare as if a diffing algorithm had been used.

However, in some cases a simple change to a reactive stream may impact large parts of the DOM, which will lead to a slower redraw. In these cases, performances may still be tuned easily in different ways.

Exploiting observables

Observable streams are very powerful abstractions, and can be manipulated to optimize their performances.

For instance, RxJS provides the following features for improving how streams are processed:

Customizing the rendering process

As stated in the overview, Ricochet can render many kinds of nodes. However, a supported type of node that wasn't mentioned before is the CustomNode.

Nodes that implement this interface are given full control over how they and their children are rendered, making operations such as batch processing and node caching extremely easy to implement.

For instance, here is how you would implement a node that only updates if a condition is respected:

class PredicateNode implements CustomNode {
  constructor(
    readonly node: NestedNode,
    readonly predicate: (node: NestedNode) => boolean
  ) {}

  render(parent: Element, prev: NodeRef, next: NodeRef, r: RenderFunction) {
    // NodeRef is equivalent to [Node]; it is used to override variables accross function bodies.
    if (!this.predicate(this.node))
      return

    r(this.node, prev, next)
  }
}

Caching elements

Since all Ricochet elements are regular DOM elements, we may want to cache elements that we know we may have to reuse.

const App = ({ dialogProps, ...props }) => {
  const dialogOpen$ = subject(false)
  
  // Keep a reference to the dialog here, so that it is only rendered once
  const dialog = <Dialog { ...dialogProps } />

  return (
    <div class='app'>
      <Content { ...props } />

      { dialogOpen$.map(dialogOpen => dialogOpen && dialog) }
    </div>
  )
}

This is unfortunately not that simple. Here, as soon as dialog gets rendered for the first time, some resources will be attached to it via attach. Then, as soon as dialogOpen$ becomes false, all these resources will be disposed of, and dialog, while still existing, will be invalid.

Therefore, we must tell Ricochet that dialog is owned by App, and that it should only be disposed when App itself is disposed of.

// This line:
const dialog = <Dialog { ...dialogProps } />

// Becomes this line:
const dialog = <Dialog noimplicitdispose { ...dialogProps } />

Now, when dialog is removed by its parent element, it will not be disposed automatically.
In order to remove it, element.destroy(true) must be called, true precising that it the element will be both destroyed (removed from the DOM) and disposed.

A common pattern is to cache an element in a component, and to dispose it when the parent component itself is disposed, rather than when the element is hidden. This can be easily accomplished by calling attach with an element rather than with a subscription.

const TodoList = ({ todos }: { todos: Todo[] }) => {
  const cache: Record<number, TodoItem> = {}

  for (const todo of todos) {
    const todoItem = <TodoItem text={todo.text} done={todo.done} noimplicitdispose />

    attach(cache[todo.id] = todoItem)
  }

  const query$: Subject<string> = subject('')

  return (
    <div>
      <input connect={valueBinder(query$)} />

      <ul>
        { query$.map(query =>
            todos
              .filter(todo => todo.text.includes(query))
              .map(todo => cache[todo.id])
        ) }
      </ul>
    </div>
  )
}

In the above example, if noimplicitdispose had not been used, each TodoItem would have been disposed as soon as it was filtered out by the query, making subsequent renders invalid.

Thanks to noimplicitdispose, TodoItems will only be disposed when TodoList itself is disposed.

Built-in optimizations

Ricochet provides several utilities designed to make things faster, which are listed below. Other utilities may be added later on, such as keyed lists, memoization, and batch rendering.

Efficient list rendering

The ricochet/array module provides the observableArray<T> function, which takes an array and returns an ObservableArray<T>. This specialized array provides the same interface as a regular array, but is able to efficiently map changes to its underlying data to the DOM by implementing CustomNode.

const numbers = observableArray<number>()

return (
  <div>
    {/* 'push' is specialized to directly add new nodes to the DOM,
        without updating the rest of the elements. */}
    <button onclick={() => numbers.push(numbers.length)}>Add number</button>

    {/* 'sync' returns a `ReadonlyObservableArray` that is synced with its source. */}
    { numbers.sync(x => <h2>{x}</h2>) }
  </div>
)

Examples, unit tests and benchmarks

Jest unit tests are available in the src directory, and benchmarks will be added shortly.

Notice: Due to Jest (or rather jsdom) not supporting Web Components and behaving differently from the browser, tests currently fail if ran via Jest. While waiting for these bugs to be fixed, the examples page currently runs all tests in the browser, ensuring that Ricochet keeps working as intended.

Tips

  • Sometimes, a component may want to receive either an observable value or a regular value, depending on what the caller needs. However, in most cases T and Observable<T> have very different interfaces, which makes it hard to manipulate one or the other via a single API. In cases where an Observable<T> could be accepted, it is best to always accept an Observable<T> sequence, since such sequences can be made to emit a single element before completing, therefore acting exactly like a regular function (see: constant, of).
    Since these observable sequences complete right after emitting an item, their resources will be disposed of quickly, and it will be as if a non-observable value had been used.

    Additionally, the built-in functions combine and compute both accept observable sequences as inputs, but may receive non-observable values, avoiding a useless subscription / unsubscription operation.

  • Ricochet was designed with RxJS's API in mind, but works with many different reactive libraries. In fact, it only requires of observable values to define a way to subscribe to them; therefore the constant / of observable may be implement as follows:

    function of<T>(value: T): Subscribable<T> & Observable<T> {
      const observable = {
        [Symbol.observable]() {
          return observable
        },
    
        subscribe(observer: Observer<T>) {
          observer.next(value)
          observer.complete()
        }
      }
    
      return observable
    })

API

The core Ricochet API, used to render JSX nodes.

Defines an observer.

Defined as:

type Observer<T> = ((newValue: T) => void) | {
  next(newValue: T): void
  complete?(): void
}

Defines a subscription, which can be unsubscribed of.

Cancels the subscription, disposing of resources and cancelling pending operations.

Defines a value whose changes can be subscribed to.

Parameter Type Description
observer Observer<T> None

Subscribes to changes to the value.

Defines an observable value.

This interface defines the bare minimum for Ricochet to interface with reactive libraries that implement this ECMAScript Observable proposal, such as RxJS.

Defines a value that may be Observable.

Defined as:

type MaybeObservable<T> = Observable<T> | T

An arbitrarily nested DOM Node.

Defined as:

type NestedNode = Node | CustomNode | string | number | NodeArray | ObservableNode

An observable NestedNode.

A list of NestedNodes.

A mutable reference to a Node.

Defined as:

type NodeRef = [Node]

The function used to render a NestedNode.

Defined as:

type RenderFunction = (value: NestedNode, previous: NodeRef, next: NodeRef) => void

A custom-rendered node.

Parameter Type Description
parent Element None
previous NodeRef None
next NodeRef None
r RenderFunction None

Renders the node in the DOM, as a child of the given parent.

In Ricochet, nodes must be rendered between other nodes. Since a single CustomNode may be rendered as several DOM nodes, these DOM nodes should be inserted before next, and previous must be set to the first node that was inserted.

  • T: Node

Defines an element that can be connected to a node.

Defined as:

type Connectable<T extends Node> =
    ((element: T, attachSubscriptions: (...subscriptions: Subscription[]) => void) => void)
  | { connect: (element: T, attachSubscriptions: (...subscriptions: Subscription[]) => void) => void }
  • Props: object
  • ReturnType: Node | Observable<Node>

Defines a function that takes properties and returns a self-updating element.

Defined as:

type Component<Props extends object, ReturnType extends Node | Observable<Node>> = (props: Props) => ReturnType
  • Tag: keyof JSX.IntrinsicElements
Parameter Type Description
tag Tag None
attrs JSX.IntrinsicAttributes & WritablePart<JSX.IntrinsicElements[Tag]> None
children NodeArray None

Renders an intrinsic element.

Parameter Type Description
tag string None
attrs JSX.IntrinsicAttributes & WritablePart<Element> None
children NodeArray None

Renders an unknown intrinsic element.

  • P: object
  • E: JSX.Element
  • K: Component<P, E>
Parameter Type Description
component K None
props JSX.IntrinsicAttributes & P & WritablePart<E> None
children NodeArray None

Renders a component.

Parameter Type Description
subscriptions `(Subscription Element)[]`

Attaches the given subscriptions or explicitly-disposed elements to the element that is currently being initialized.

Parameter Type Description
node ObservableNode None

Mounts an observable node as a simple element.

Parameter Type Description
node NestedNode None
el Element None

Mounts the given observable node as a child of the given element.

  • N: Node
  • E: Event
Parameter Type Description
type string None
opts `boolean AddEventListenerOptions`

Returns a Connectable<T> that can be used to register to events on one or more elements.

Utilities for rendering lists efficiently with the ObservableArray type.

Defines an object that can listen to changes to an ObservableArray.

Defined as:

type ArrayObserver<T> = {
  [key in string & keyof Array<T>]?:
    Array<T>[key] extends (...args: infer Args) => any
      ? (...args: Args) => void
      : never
} & {
  set: (index: number, value: T) => void
}

Interfaces which exports all members implemented by ReadonlyObservableArray<T>, but not ReadonlyArray<T>. Required by TypeScript.

Returns an observable sequence that emits whenever the length of this array changes.

Returns an observable sequence that emits whenever an item is modified.

Parameter Type Description
observer ArrayObserver<T> None
init boolean If true, push will be called on initialization with the content of the array.

Observes changes made to the array.

Parameter Type Description
f (array: ReadonlyArray<T>) => R None
thisArg any None

Returns an observable sequence that gets updated everytime this array changes.

Parameter Type Description
f (value: T, index: number) => R None

Propagates changes to the items of the given list to items of a new list, according to a map function.

Defines a readonly array whose changes can be observed.

Defines an array whose changes can be observed.

Parameter Type Description
f (value: T, index: number) => R None
g (value: R, index: number) => T None

Propagates changes to the items of the given list to items of a new list, and back again.

Parameter Type Description
a number None
b number None

Swaps the values at the two given indices.

Parameter Type Description
array any None

Returns whether the given array is an ObservableArray.

Utilities for rendering with promises.

  • E: Element
Parameter Type Description
component Promise<E> None

Returns an element that will be replaced by the result of the given promise when it resolves.

  • P: {}
  • E: Element
Parameter Type Description
component (props: P) => Promise<E> None

Wraps a component that asynchronously resolves as a regular component whose element will be replaced once the promise resolves.

  • P: {}
  • E: Element
  • K: {}
Parameter Type Description
component Component<P & K, E> None
props Promise<K> None

Given a component, some of its properties, and a promise that resolves to the rest of its properties, returns an element that will be replaced by the resolved element when the promise finishes.

  • P: {}
  • E: Element
Parameter Type Description
component Promise<Component<P, E>> None
props P None

Given a promise that resolves to a component and its properties, returns an element that will be replaced by the resolved element when the promise finishes.

Utilities for creating and combining observable streams and subjects.

Defines a value that is both observable and observer.

Parameter Type Description
newValue T None

Updates the underlying value, notifying all observers of a change.

Parameter Type Description
value any None

Returns whether the given value is a subject created with subject.

Subject augmented with some specialized operations, returned by subject.

Gets or sets the underlying value.

Parameter Type Description
value T None

Sets the underlying value without notifying observers.

Parameter Type Description
map (input: T) => R None

Returns a new Observable that gets updated when this subject changes.

Parameter Type Description
map (input: T) => R None
unmap (input: R) => T None

Returns a new Subject value that propagates changes to values both ways.

Parameter Type Description
initialValue T None

Returns a reactive wrapper around the given value.

Subscrible that represents a constant value, returned by constant.

Parameter Type Description
value T None

Returns an observable value that emits a single value, and then immediately completes.

This function should be used when an observable stream is expected somewhere, but a single constant value can be provided.

An Observable that computes its values based on an arbitrary computation, which may itself depend on other observable sequences; returned by compute.

Parameter Type Description
computation ($: <U>(observable: Observable<U>, defaultValue?: U) => U) => T None

Returns an observable that will be updated when any of the given observables changes.

See S.js for the inspiration for this function. Please note that unlike S, changes are propagated immediately, without waiting for the next time unit.

Example
const a = subject(1)
const b = subject(1)

const c = compute($ => $(a) + $(b))

c.subscribe(console.log).unsubscribe() // Prints '2'

a.next(10)

c.subscribe(console.log) // Prints '11'

b.next(20) // Prints '30'

Maps Observable<T> properties of an object to T.

Defined as:

type ObservableValueTypes<O> = {
  [K in keyof O]: O[K] extends Observable<infer T> ? T : O[K]
}

An Observable sequence that emits values when any of its dependencies is updated; returned by combine.

Interop helpers for RxJS.

Utilities for defining Web Components.

  • P: object
  • E: JSX.Element
Parameter Type Description
component Component<P, E> None
translateProperties object & { [K in keyof P]: (value?: string) => P[K] } None

Creates a custom element (or web component) out of a JSX component.

About

Tiny (< 5kB) framework for efficient and direct DOM rendering, using JSX / observables.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published