UI Rig Internals
Desktop-like SPA Architecture

by Eric Fortis

UI Rig is a single-page application that:


Let’s start with the continuous saving mechanism, for which there are three general options:

  • Option 1: Collecting how to reproduce each action.
  • Option 2: Saving a complete snapshot after every action.
  • Option 3: A hybrid, such as saving on immutable data structures, for instance, Immutable.js.

We’ll discuss Option 1 as it’s the most simple and versatile.

Reproducing Actions

Let’s call Undo Frame to the data needed for replaying an action. For example, the following frame updates the title field of a Card with a certain id.

[
    [BASE.setCard, 'idABC', CF.title, 'The New Title']
]

This way undo is a matter of reverting to a previous snapshot, and replaying each change up to the penultimate.

Some undo steps have many actions. For example, moving an Entry to another Card.

UI Rig Moving an Entry to Another Card

Behind the scenes that function has a removeEntry('cardA', …) and an insertEntry('cardB', …). Therefore, we use a transaction to collect all the calls to the underlying BASE setters into a single undo frame.

function moveEntryToAnotherCard(fromCardId, entryId, toCardId) {
    const endTx = TransactionRecorder();
    // …
    _removeEntry(fromCardId, entryId);
    _insertEntry(toCardId, …);
    endTx();
}

Caveats

There’s an important part missing in the previous snippet. How to copy the properties of the removed Entry?

In contrast to an immutable data structure, Option 3, we can’t simply edit non-primitives, such as objects and arrays. Instead, we have to create a new one. Otherwise, the edit would inadvertently change them in the undo stack as well.

For example, outputLinks is an array of Entry Pointer objects. So to move an Entry to another Card, we have to deep clone that array, and assign it to the newly inserted Entry.

UI Rig an Entry with many output links to share a UI constraint

Pros

The undo frames are extendable with metadata. For example, for squashing many frames into one, such as a burst of a slider.

UI Rig Fixed Decimals slider input field

As a side note, they could also be used for synchronizing multiple users in real-time. It’s not implemented in UI Drafter, but it’s the basis of Meteor’s Distributed Data Protocol.

Data Flow


localStorage File System IndexedDB UI States Memory Frontend Compress Decompress Sanitize File Data Memory Stores API Transactions Engine Memory Middleware

Frontend

These are the UI components, for example:

  • card/Card.js
  • toolbar/PreviewButton.js

The frontend memory objects, UI States, are non-undoable actions, for example whichMenuIsOpen or whatIsSelected. Some of them are persisted to the browser’s localStorage, like toolbarIsVisible and scrollbarsAreVisible.

UI Rig UI States Showing Edit Menu and Multi-selection of Entries

React.js

React-wise, the state-controlled components need static getDerivedStateFromProps(props, state) in order to restore a previous value when undoing, and for real-time updates.

Open Source

The reactive-state library has more details.

Layout Rendering

In the main board, Cards are absolutely positioned. For instance, it could be painted in an HTML <canvas>. The main reason is performance. This way there’s no need to query the DOM, which is a slow operation, because we can compute the position of the connectors, among other elements from plain JS objects.

Middleware (Business Layer)

The middleware files are the ‘app-specific’ algorithms. I think of them as the engine, or as the fun algorithms to write. The rule is that they don’t query or need the UI; they are exclusively fed from the stores. Here are some examples:

connectors/
locateConnectorPoints.js

It’s used for drawing the connections, and for finding the Entries within a marquee region.

UI Rig Marquee Selecting Entries

numeric/
computeAllCards.js

Determines the dependency order for computing formulas and evaluating user-JavaScript. For example, Nested Cards are like parentheses, so the deepest ones get computed first.

UI Rig Showing Computing Graph Hierarchy

links/
LoopDetection.js

The loop detection algorithms prevent creating connections that would cause infinite loops. For example, when trying to link a total that depends on another Card into that Card.

UI Rig Showing how infinite loops get prevented

Memory

The middleware engine has some memory objects too. For example, for the connector points, and caching payload relevant fields for Cards with JavaScript code.

Stores API

The stores memory contains the end-user File. Its data is JSON-safe, and it’s saved automatically in the browser’s IndexedDB after every change, Option 2. Therefore, if there are many tabs with the same file open, the one on the last edited tab wins. Also, the corresponding undo stack is saved there too, so it can be restored across sessions for unlimited undo levels.

Although UI Rig doesn’t save to the server-side, that wouldn’t be too different from saving to the local file system.

By convention, these files are under **/api directories. For example:

  • app/api/BaseSetters.js
  • card/api/CardGetters.js
  • card/api/internal/_updateCardTitle.js

Our indexedDB library is open-source.

Compressor

The compressor is mainly for removing the default Card and Entry fields. You can see that compressed JSON file by clicking File → Save.

Sanitization

To prevent prototype pollution attacks, all the constructors are instantiated with their defaults, and sealed with Object.seal, before assigning or merging new properties.

End

Don’t forget to check out UI Rig, uirig.com

Sponsored by: