torus.js annotated source

Back to index

        
1// @begindebug

These utility functions enable rich debugging statements during development, when using the development build (dist/torus.dev.js). These give you hierarchical information about what components are being rendered, and how.

6

Flag to enable rich debugging during renders

8const DEBUG_RENDER = true;
9

Repeat a string count times. Used to indent in render_debug.

11const repeat = (str, count) => {
12    let s = '';
13    while (count -- > 0) {
14        s += str;
15    }
16    return s;
17}
18

Main rich debug logger function. render_debug() depends on the render_stack counter in our rendering algorithm to figure out how deep in the render tree we are, and indent the message to the level appropriate to our place in the render tree.

24const render_debug = (msg, header = false) => {
25    if (DEBUG_RENDER) {
26        if (header) {

We want to pull forward headers in front of their section contents, so we de-indent 1.

29            const prefix = repeat('\t', render_stack - 1);
30            console.log('%c' + prefix + msg, 'font-weight: bold');
31        } else {
32            const prefix = repeat('\t', render_stack);
33            console.log(prefix + msg);
34        }
35    }
36}
37

Helper function for debugging logs where we want to print a JDOM node in the most appropriate way, depending on type.

40const printNode = node => {
41    if (node === null) {
42        return '<!---->';
43    } else if (node.tag) {
44        return `<${node.tag.toLowerCase()}>`;
45    } else if (node.tagName) {
46        return `<${node.tagName.toLowerCase()}>`;
47    } else if (typeof node === 'string' || typeof node === 'number') {
48        return `"${node}"`;
49    } else if (node.nodeType === 3) {
50        return `text node "${node.data}"`;
51    }
52    return node.toString();
53}
54
55// @enddebug
56

A global counter for how deep we are in our render tree. 0 indicates that we aren't in the middle of rendering.

59let render_stack = 0;
60

Shortcut utility function to check if a given name is bound to something that's an actual object (not just null). We perform the null check first because that's faster.

64const isObject = obj => obj !== null && typeof obj === 'object';
65

normalizeJDOM takes a JDOM object (dictionary) and modifies it in place so it has the default JDOM properties, and we don't have to complicate our rendering code by checking for nulls with every key access into our serialized virtual DOM. Note that we don't check isObject(jdom) here. We assume only valid objects are passed in to 'normalize', which is true in our usage so far. normalizeJDOM is a hot path in rendering, so we need it as fast as it can be.

74const normalizeJDOM = jdom => {
75    if (jdom.attrs === undefined) {
76        jdom.attrs = {};
77    }
78    if (jdom.events === undefined) {
79        jdom.events = {};
80    }
81    if (jdom.children === undefined) {
82        jdom.children = [];
83    }
84}
85

Quick shorthand to normalize either 1. a single value or 2. an array of values into an array of values. This is useful because JDOM accepts either into things like attrs.class and events.<name>.

89const arrayNormalize = data => Array.isArray(data) ? data : [data];
90

We use comment nodes as placeholder nodes because they're lightweight and invisible.

93const tmpNode = () => document.createComment('');
94

opQueue is a global queue of node-level operations to be performed. These are calculated during the diff, but because operations touching the page DOM are expensive, we defer them until the end of a render pass and run them all at once, asynchronously. Each item in the queue is an array that starts with an opcode (one of the three below), and is followed by the list of arguments the operation takes. We render all operations in the queue to the DOM before the browser renders the next frame.

102let opQueue = [];
103const OP_APPEND = 0; // append, parent, new
104const OP_REMOVE = 1; // remove, parent, old
105const OP_REPLACE = 2; // replace, old, new

This is a stubbed parentNode. See below in runDOMOperations for why this exists.

107const STUB_PARENT = {
108    replaceChild: () => {},
109};
110

runDOMOperations works through the opQueue and performs each DOM operation in order they were queued. rDO is called when the reconciler (render) reaches the bottom of a render stack (when it's done reconciling the diffs in a root-level JDOM node of a component).

115function runDOMOperations() {

This function is written to avoid any potential reconciliation conflicts. There are two risks to mitigate: 1. attempting insert a node that is already in the DOM, and 2. attempting remove a node that isn't in the DOM. Both will result in inconsistent DOM state and break the renderer. To avoid this, first, we remove all children and add placeholders where they ought to be replaced. Then, in a second loop, we add any children that need to be added and replace placeholders. Thus, no children will be inadvertently removed and no wrong node will be removed.

124    const len = opQueue.length;
125    for (let i = 0; i < len; i ++) {
126        const next = opQueue[i];
127        const op = next[0];
128        if (op === OP_REMOVE) {

Remove all children that should be

130            next[1].removeChild(next[2]);
131        } else if (op === OP_REPLACE) {

For the ones queued to for being replaced, put in a placeholder node, and queue that up instead.

134            const oldNode = next[1];
135            const tmp = tmpNode();
136            const parent = oldNode.parentNode;

Sometimes, the given node will be a standalone node (like the root of an unmounted component) and will have no parentNode. In these rare cases, it's best for performance to just set the parent to a stub with a no-op replaceChild. Trying to check for edge cases later each time is a performance penalty, since this is a very rare case.

142            if (parent !== null) {
143                parent.replaceChild(tmp, oldNode);
144                next[1] = tmp;
145                next[3] = parent;
146            } else {
147                next[3] = STUB_PARENT;
148            }
149        }
150    }
151    for (let i = 0; i < len; i ++) {
152        const next = opQueue[i];
153        const op = next[0];
154        if (op === OP_APPEND) {

Add any node that need to be added

156            next[1].appendChild(next[2]);
157        } else if (op === OP_REPLACE) {

Replace placeholders with correct nodes. This is equivalent to parent.replaceChild(newNode, oldNode)

160            next[3].replaceChild(next[2], next[1]);
161        }
162    }
163    opQueue = [];
164}
165

A function to compare event handlers in render

167const diffEvents = (whole, sub, cb) => {
168    for (const eventName of Object.keys(whole)) {
169        const wholeEvents = arrayNormalize(whole[eventName]);
170        const subEvents = arrayNormalize(sub[eventName] || []);
171        for (const handlerFn of wholeEvents) {

Sometimes, it's nice to be able to pass in non-function values to event objects in JDOM, because we may be toggling the presence of an event listener with a ternary expression, for example. We only attach function handlers here.

175            if (!subEvents.includes(handlerFn) && typeof handlerFn === 'function') {
176                cb(eventName, handlerFn);
177            }
178        }
179    }
180}
181

Torus's virtual DOM rendering algorithm that manages all diffing, updating, and efficient DOM access. render takes node, the previous root node; previous, the previous JDOM; and next, the new JDOM; and returns the new root node (potentially different from the old root node.) Whenever a component is rendered, it calls render. This rendering algorithm is recursive into child nodes. Despite not touching the DOM, this is still one of the most expensive parts of rendering.

189const render = (node, previous, next) => {
190

This queues up a node to be inserted into a new slot in the DOM tree. All queued replacements will flush to DOM at the end of the render pass, from runDOMOperations.

194    const replacePreviousNode = newNode => {
195        if (node && node !== newNode) {
196            opQueue.push([OP_REPLACE, node, newNode]);
197        }
198        node = newNode;
199    };
200

We're rendering a new node in the render tree. Increment counter.

202    render_stack ++;
203

We only do diff operations if the previous and next items are not the same.

205    if (previous !== next) {

If we need to render a null (comment) node, create and insert a comment node. This might seem silly, but it keeps the DOM consistent between renders and makes diff simpler.

210        if (next === null) {
211            // @begindebug
212            if (node === undefined) {
213                render_debug('Add comment node');
214            } else {
215                render_debug(`Replace previous node ${printNode(previous)} with comment node`);
216            }
217            // @enddebug
218            replacePreviousNode(tmpNode());

If we're rendering a string or raw number, convert it into a string and add a TextNode.

221        } else if (typeof next === 'string' || typeof next === 'number') {
222            // @begindebug
223            if (node === undefined) {
224                render_debug(`Add text node "${next}"`);
225            } else {
226                render_debug(`Replace previous node ${printNode(previous)} with text node "${next}"`);
227            }
228            // @enddebug

If the previous node was also a text node, just replace the .data, which is very fast (as of 5/2019 faster than .nodeValue, .textContent, and .innerText). Otherwise, create a new TextNode.

231            if (typeof previous === 'string' || typeof previous === 'number') {
232                node.data = next;
233            } else {
234                replacePreviousNode(document.createTextNode(next));
235            }

If we need to render a literal DOM Node, just replace the old node with the literal node.

238        } else if (next.appendChild !== undefined) { // check if next instanceof Node; fastest way is checking for presence of a non-getter property
239            // @begindebug
240            if (node === undefined) {
241                render_debug(`Add literal element ${printNode(next)}`);
242            } else {
243                render_debug(`Replace literal element ${printNode(previous)} with literal element ${printNode(next)}`);
244            }
245            // @enddebug
246            replacePreviousNode(next);

If we're rendering an object literal, assume it's a serialized JDOM dictionary. This is the meat of the algorithm.

249        } else { // next is a non-null object
250            // @debug
251            render_debug(`Render pass for <${next.tag}>:`, true);
252
253            if (
254                node === undefined
255                || !isObject(previous)

Check if previous instanceof Node; fastest way is checking for presence of a non-getter property, like appendChild.

258                || (previous && previous.appendChild !== undefined)

If the tags differ, we assume the subtrees will be different as well and just start a completely new element. This is efficient in practice, reduces the time complexity of the algorithm, and an optimization shared with React's reconciler.

263                || previous.tag !== next.tag
264            ) {
265                // @begindebug
266                if (node === undefined) {
267                    render_debug(`Add <${next.tag}>`);
268                } else {
269                    render_debug(`Replace previous node ${printNode(previous)} with <${next.tag}>`);
270                }
271                // @enddebug

If the previous JDOM doesn't exist or wasn't JDOM, we're adding a completely new node into the DOM. Stub an empty previous.

274                previous = {
275                    tag: null,
276                };
277                replacePreviousNode(document.createElement(next.tag));
278            }
279            normalizeJDOM(previous);
280            normalizeJDOM(next);
281

Compare and update attributes

283            for (const attrName of Object.keys(next.attrs)) {
284                const pAttr = previous.attrs[attrName];
285                const nAttr = next.attrs[attrName]
286
287                if (attrName === 'class') {

JDOM can pass classes as either a single string or an array of strings, so we need to check for either of those cases.

291                    const nextClass = nAttr;

Mutating className is faster than iterating through classList objects if there's only one batch operation for all class changes.

295                    if (Array.isArray(nextClass)) {
296                        // @begindebug
297                        if (node.className !== nextClass.join(' ')) {
298                            render_debug(`Update class names for <${next.tag}> to "${nextClass.join(' ')}"`);
299                        }
300                        // @enddebug
301                        node.className = nextClass.join(' ');
302                    } else {
303                        // @begindebug
304                        if (node.className !== nextClass) {
305                            render_debug(`Update class name for <${next.tag}> to ${nextClass}`);
306                        }
307                        // @enddebug
308                        node.className = nextClass;
309                    }
310                } else if (attrName === 'style') {

JDOM takes style attributes as a dictionary rather than a string for API ergonomics, so we serialize it differently than other attributes.

314                    const prevStyle = pAttr || {};
315                    const nextStyle = nAttr;
316

When we iterate through the key/values of a flat object like this, you may be tempted to use Object.entries(). We use Object.keys() and lookups, which is less idiomatic, but fast. This results in a measurable performance bump.

320                    for (const styleKey of Object.keys(nextStyle)) {
321                        if (nextStyle[styleKey] !== prevStyle[styleKey]) {
322                            // @debug
323                            render_debug(`Set <${next.tag}> style ${styleKey}: ${nextStyle[styleKey]}`);
324                            node.style[styleKey] = nextStyle[styleKey];
325                        }
326                    }
327                    for (const styleKey of Object.keys(prevStyle)) {
328                        if (nextStyle[styleKey] === undefined) {
329                            // @debug
330                            render_debug(`Unsetting <${next.tag}> style ${styleKey}: ${prevStyle[styleKey]}`);
331                            node.style[styleKey] = '';
332                        }
333                    }

If an attribute is an IDL attribute, we set it through JavaScript properties on the HTML element and not setAttribute(). This is necessary for properties like value and indeterminate.

338                } else if (attrName in node) {
339                    // @debug
340                    render_debug(`Set <${next.tag}> property ${attrName} = ${nAttr}`);

We explicitly make a comparison here before setting, because setting reflected HTML properties is not idempotent -- on some elements like audio, video, and iframe, setting properties like src will call a setter that sometimes resets UI state in some browsers. We must compare the new value to DOM directly and not a previous JDOM value, because they differ sometimes when the DOM mutates from under Torus's control, like on a user input. We also guard against cases where the DOM has a default value (like input.type) but we want to still specify a value manually, by checking if pAttr was defined.

348                    if (node[attrName] !== nAttr || (pAttr === undefined && pAttr !== nAttr)) {
349                        node[attrName] = nAttr;
350                    }
351                } else {
352                    if (pAttr !== nAttr) {
353                        // @debug
354                        render_debug(`Set <${next.tag}> attribute "${attrName}" to "${nAttr}"`);
355                        node.setAttribute(attrName, nAttr);
356                    }
357                }
358            }
359

For any attributes that were removed in the new JDOM, also attempt to remove them from the DOM.

362            for (const attrName of Object.keys(previous.attrs)) {
363                if (next.attrs[attrName] === undefined) {
364                    if (attrName in node) {
365                        // @debug
366                        render_debug(`Remove <${next.tag} property ${attrName}`);

null seems to be the default for most IDL attrs, but even this isn't entirely consistent. This seems like something we should fix as issues come up, not preemptively search for a cross-browser solution.

371                        node[attrName] = null;
372                    } else {
373                        // @debug
374                        render_debug(`Remove <${next.tag}> attribute ${attrName}`);
375                        node.removeAttribute(attrName);
376                    }
377                }
378            }
379
380            diffEvents(next.events, previous.events, (eventName, handlerFn) => {
381                // @debug
382                render_debug(`Set new ${eventName} event listener on <${next.tag}>`);
383                node.addEventListener(eventName, handlerFn);
384            });
385            diffEvents(previous.events, next.events, (eventName, handlerFn) => {
386                // @debug
387                render_debug(`Remove ${eventName} event listener on <${next.tag}>`);
388                node.removeEventListener(eventName, handlerFn);
389            });
390

Render children recursively. These loops are also well optimized, since it's a hot patch of code at runtime. We memoize generated child nodes into this previous._nodes array so we don't have to perform expensive, DOM-touching operations during reconciliation to look up children of the current node in the next render pass. nodeChildren will be updated alongside enqueued DOM mutation operations. In the future, we may also look at optimizing more of the common cases of list diffs as domdiff does, before delving into a full iterative diff of two lists.

400            const prevChildren = previous.children;
401            const nextChildren = next.children;

Memoize length lookups.

403            const prevLength = prevChildren.length;
404            const nextLength = nextChildren.length;

Smaller way to check for "if either nextLength or prevLength is greater than zero"

406            if (nextLength + prevLength > 0) {

Initialize variables we'll need / reference throughout child reconciliation.

408                const nodeChildren = previous._nodes || [];
409                const minLength = prevLength < nextLength ? prevLength : nextLength;
410

"sync" the common sections of the two children lists.

412                let i = 0;
413                for (; i < minLength; i ++) {
414                    if (prevChildren[i] !== nextChildren[i]) {
415                        nodeChildren[i] = render(nodeChildren[i], prevChildren[i], nextChildren[i]);
416                    }
417                }

If the new JDOM has more children than the old JDOM, we need to add the extra children.

420                if (prevLength < nextLength) {
421                    for (; i < nextLength; i ++) {
422                        // @begindebug
423                        if (nextChildren[i].tagName) {
424                            render_debug(`Add child ${printNode(nextChildren[i])}`);
425                        } else if (nextChildren[i].tag) {
426                            render_debug(`Add child ${printNode(nextChildren[i])}`);
427                        } else {
428                            render_debug(`Add child "${nextChildren[i]}"`);
429                        }
430                        // @enddebug
431                        const newChild = render(undefined, undefined, nextChildren[i]);
432                        opQueue.push([OP_APPEND, node, newChild]);
433                        nodeChildren.push(newChild);
434                    }

If the new JDOM has less than or equal number of children to the old JDOM, we'll remove any stragglers.

437                } else {
438                    for (; i < prevLength; i ++) {
439                        // @begindebug
440                        if (prevChildren[i].tagName) {
441                            render_debug(`Remove child ${printNode(prevChildren[i])}`);
442                        } else if (prevChildren[i].tag) {
443                            render_debug(`Remove child ${printNode(prevChildren[i])}`);
444                        } else {
445                            render_debug(`Remove child "${prevChildren[i]}"`);
446                        }
447                        // @enddebug

If we need to remove a child element, removing it from the DOM immediately might lead to race conditions. instead, we add a placeholder and remove the placeholder at the end.

452                        opQueue.push([OP_REMOVE, node, nodeChildren[i]]);
453                    }
454                    nodeChildren.splice(nextLength, prevLength - nextLength);
455                }

Mount nodeChildren onto the up-to-date JDOM, so the next render pass can reference it.

458                next._nodes = nodeChildren;
459            }
460        }
461    }
462

We're done rendering the current node, so decrement the render stack counter. If we've reached the top of the render tree, it's time to flush replaced nodes to the DOM before the next frame.

467    if (-- render_stack === 0) {

runDOMOperations() can also be called completely asynchronously with utilities like requestIdleCallback, a la Concurrent React, for better responsiveness on larger component trees. This requires a modification to Torus's architecture, so that each set of DOMOperations tasks in the opQueue from one component's render call are flushed to the DOM before the next component's DOMOperations begins, for consistency. This can be achieved with a nested queue layer on top of opQueue. Here, we omit concurrency support today because it's not a great necessity where Torus is used.

477        runDOMOperations();
478    }
479
480    return node;
481}
482

Shorthand function for the default, empty event object in Component.

484const emptyEvent = () => {
485    return {
486        source: null,
487        handler: () => {},
488    }
489}
490

Torus's Component class

492class Component {
493
494    constructor(...args) {
495        this.jdom = undefined;
496        this.node = undefined;
497        this.event = emptyEvent();

We call init() before render, because it's a common pattern to set and initialize "private" fields in this.init() (at least before the ES-next private fields proposal becomes widely supported.) Frequently, rendering will require private values to be set correctly.

502        this.init(...args);

After we run #init(), we want to make sure that every constructed component has a valid #node property. To be efficient, we only render to set #node if it isn't already set yet.

506        if (this.node === undefined) {
507            this.render();
508        }
509    }
510

Component.from() allows us to transform a pure function that maps arguments to a JDOM tree, and promote it into a full-fledged Component class we can compose and use anywhere.

514    static from(fn) {
515        return class FunctionComponent extends Component {
516            init(...args) {
517                this.args = args;
518            }
519            compose() {
520                return fn(...this.args);
521            }
522        }
523    }
524

The default Component#init() is guaranteed to always be a no-op method

526    init() {
527        // should be overridden
528    }
529

Components usually subscribe to events from a Record, either a view model or a model that maps to business logic. This is shorthand to access that.

532    get record() {
533        return this.event.source;
534    }
535
536    bind(source, handler) {
537        this.unbind();
538
539        if (source instanceof Evented) {
540            this.event = {source, handler};
541            source.addHandler(handler);
542        } else {
543            throw new Error(`cannot bind to ${source}, which is not an instance of Evented.`);
544        }
545    }
546
547    unbind() {
548        if (this.record) {
549            this.record.removeHandler(this.event.handler);
550        }
551        this.event = emptyEvent();
552    }
553

We use #remove() to prepare to remove the component from our application entirely. By default, it unsubscribes from all updates. However, the component is still in the render tree -- that's something for the user to decide when to hide.

558    remove() {
559        this.unbind();
560    }
561

#compose() is our primary rendering API for components. By default, it renders an invisible comment node.

564    compose() {
565        return null;
566    }
567

#preprocess() is an API on the component to allow us to extend Component to give it additional capabilities idiomatically. It consumes the result of #compose() and returns JDOM to be used to actually render the component. See Styled() for a usage example.

572    preprocess(jdom) {
573        return jdom;
574    }
575

#render() is called to actually render the component again to the DOM, and Torus assumes that it's called rarely, only when the component absolutely must update. This obviates the need for something like React's shouldComponentUpdate.

579    render(data) {
580        // @debug
581        render_debug(`Render Component: ${this.constructor.name}`, true);
582        data = data || (this.record && this.record.summarize())
583        const jdom = this.preprocess(this.compose(data), data);
584        if (jdom === undefined) {

If the developer accidentally forgets to return the JDOM value from compose, instead of leading to a cryptic DOM API error, show a more friendly warning.

588            throw new Error(this.constructor.name + '.compose() returned undefined.');
589        }
590        try {
591            this.node = render(this.node, this.jdom, jdom);
592        } catch (e) {
593            /* istanbul ignore next: haven't found a reproducible error case that triggers this */
594            console.error('rendering error.', e);
595        }
596        return this.jdom = jdom;
597    }
598
599}
600

We keep track of unique class names already injected into the page's stylesheet, so we don't do redundant style reconciliation.

603const injectedClassNames = new Set();
604

Global pointer to the stylesheet on the page that Torus uses to insert new CSS rules. It's set the first time a styled component renders.

607let styledComponentSheet;
608

A weak (garbage-collected keys) cache for mapping styles objects to hashes class names. If we use the css template tag or cache the styles object generated in a component in other ways, it's substantially faster to do a shallow comparison of styles objects and cache unique classnames than to compare the styles objects deeply every time. This cache implements this without a huge memory hit in the case of non-cached styles objects, because WeakMap's keys are garbage collected.

616const INJECTED_STYLES_CACHE = new WeakMap();
617

Fast hash function to map a style rule to a very reasonably unique class name that won't conflict with other classes on the page. Checks the styles cache first.

620const generateUniqueClassName = stylesObject => {
621    if (!INJECTED_STYLES_CACHE.has(stylesObject)) {
622        // Modified from https://github.com/darkskyapp/string-hash/blob/master/index.js
623        const str = JSON.stringify(stylesObject);
624        let i = str.length;
625        let hash = 1989;
626        while (i) {
627            hash = (hash * 13) ^ str.charCodeAt(-- i);
628        }
629        INJECTED_STYLES_CACHE.set(stylesObject, '_torus' + (hash >>> 0));
630    }
631    return INJECTED_STYLES_CACHE.get(stylesObject);
632}
633

We have to construct lots of a{b} syntax in CSS, so here's a shorthand.

635const brace = (a, b) => a + '{' + b + '}';
636

The meat of Styled(). This function maps an ergonomic, dictionary-based set of CSS declarations to an array of CSS rules that can be inserted onto the page stylesheet, and recursively resolves nested CSS, handles keyframes and media queries, and parses other SCSS-like things.

641const rulesFromStylesObject = (selector, stylesObject) => {
642    let rules = [];
643    let selfDeclarations = '';
644    for (const prop of Object.keys(stylesObject)) {
645        const val = stylesObject[prop];

CSS declarations that start with '@' are globally namespaced (like @keyframes and @media), so we need to treat them differently.

648        if (prop[0] === '@') {
649            if (prop.startsWith('@media')) {
650                rules.push(brace(prop, rulesFromStylesObject(selector, val).join('')));
651            } else  { // @keyframes or @font-face
652                rules.push(brace(prop, rulesFromStylesObject('', val).join('')));
653            }
654        } else {
655            if (typeof val === 'object') {
656                const commaSeparatedProps = prop.split(',');
657                for (const p of commaSeparatedProps) {

SCSS-like syntax means we use '&' to nest declarations about the parent selector.

660                    if (p.includes('&')) {
661                        const fullSelector = p.replace(/&/g, selector);
662                        rules = rules.concat(rulesFromStylesObject(fullSelector, val));
663                    } else {
664                        rules = rules.concat(rulesFromStylesObject(selector + ' ' + p, val));
665                    }
666                }
667            } else {
668                selfDeclarations += prop + ':' + val + ';';
669            }
670        }
671    }
672    if (selfDeclarations) {

We unshift the self declarations to the beginning of the list of rules instead of simply pushing it to the end, because we want the nested rules to have precedence / override rules on self.

676        rules.unshift(brace(selector, selfDeclarations));
677    }
678
679    return rules;
680}
681

Function called once to initialize a stylesheet for Torus to use on every subsequent style render.

684const initSheet = () => {
685    const styleElement = document.createElement('style');
686    styleElement.setAttribute('data-torus', '');
687    document.head.appendChild(styleElement);
688    styledComponentSheet = styleElement.sheet;
689}
690

The preprocessor on Styled() components call this to make sure a given set of CSS rules for a component is inserted into the page stylesheet, but only once for a unique set of rules. We disambiguate by the class name, which is a hash of the CSS rules.

695const injectStylesOnce = stylesObject => {
696    const className = generateUniqueClassName(stylesObject);
697    let sheetLength = 0;
698    if (!injectedClassNames.has(className)) {
699        if (!styledComponentSheet) {
700            initSheet();
701        }
702        const rules = rulesFromStylesObject('.' + className, stylesObject);
703        for (const rule of rules) {
704            // @debug
705            render_debug(`Add new CSS rule: ${rule}`);
706            styledComponentSheet.insertRule(rule, sheetLength ++);
707        }
708        injectedClassNames.add(className);
709    }
710    return className;
711}
712

Higher-order component to enable styling for any Component class.

714const Styled = Base => {
715    return class extends Base {

In a styled component, the #styles() method is passed in the same data as #compose(), and returns a JSON of nested CSS.

718        styles() {
719            return {};
720        }
721
722        preprocess(jdom, data) {
723            if (isObject(jdom)) {
724                jdom.attrs = jdom.attrs || {};
725                jdom.attrs.class = arrayNormalize(jdom.attrs.class || []);
726                jdom.attrs.class.push(injectStylesOnce(this.styles(data)));
727            }
728            return jdom;
729        }
730    }
731}
732

Torus's generic List implementation, based on Stores. React and similar virtual-dom view libraries depend on key-based reconciliation during render to efficiently render children of long lists. Torus doesn't (yet) have a key-aware reconciler in the diffing algorithm, but List's design obviates the need for keys. Rather than giving the renderer a flat virtual DOM tree to render, List instantiates each individual item component and hands them off to the renderer as full DOM Node elements, so each list item manages its own rendering, and the list component only worries about displaying the list wrapper and a flat list of children items.

742
743class List extends Component {
744
745    get itemClass() {
746        return Component; // default value, should be overridden
747    }
748
749    init(store, ...itemData) {
750        this.store = store;
751        this.items = new Map();
752        this.filterFn = null;
753        this.itemData = itemData;
754
755        this.bind(this.store, () => this.itemsChanged());
756    }
757
758    itemsChanged() {

For every record in the store, if it isn't already in this.items, add it and its view; if any were removed, also remove it from this.items.

762        const data = this.store.summarize();
763        const items = this.items;
764        for (const record of items.keys()) {
765            if (!data.includes(record)) {
766                items.get(record).remove();
767                items.delete(record);
768            }
769        }
770        for (const record of data) {
771            if (!items.has(record)) {
772                items.set(
773                    record,

We pass a callback that takes a record and removes it from the list's store. It's common in UIs for items to have a button that removes the item from the list, so this callback is passed to the item component constructor to facilitate that pattern.

778                    new this.itemClass(
779                        record,
780                        () => this.store.remove(record),
781                        ...this.itemData
782                    )
783                );
784            }
785        }
786
787        let sorter = [...items.entries()];

Sort by the provided filter function if there is one

789        if (this.filterFn !== null) {
790            sorter = sorter.filter(item => this.filterFn(item[0]));
791        }

Sort the list the way the associated Store is sorted.

793        sorter.sort((a, b) => data.indexOf(a[0]) - data.indexOf(b[0]));
794

Store the new items in a new (insertion-ordered) Map at this.items

796        this.items = new Map(sorter);
797
798        this.render();
799    }
800
801    filter(filterFn) {
802        this.filterFn = filterFn;
803        this.itemsChanged();
804    }
805
806    unfilter() {
807        this.filterFn = null;
808        this.itemsChanged();
809    }
810
811    get components() {
812        return [...this];
813    }
814

List#nodes returns the HTML nodes for each of its item views, sorted in order. Designed to make writing #compose() easier.

817    get nodes() {
818        return this.components.map(item => item.node);
819    }
820

This iterator is called when JavaScript requests an iterator from a list, e.g. when for (const _ of someList) is run.

823    [Symbol.iterator]() {
824        return this.items.values();
825    }
826
827    remove() {
828        super.remove();

When we remove a list, we also want to call remove() on each child components.

831        for (const c of this.items.values()) {
832            c.remove();
833        }
834    }
835

By default, just render the children views in a <ul/>

837    compose() {
838        return {
839            tag: 'ul',
840            children: this.nodes,
841        }
842    }
843
844}
845

Higher-order component to create a list component for a given child item component.

848const ListOf = itemClass => {
849    return class extends List {
850        get itemClass() {
851            return itemClass;
852        }
853    }
854}
855

A base class for evented data stores. Not exposed to the public API, but all observables in Torus inherit from Evented.

858class Evented {
859
860    constructor() {
861        this.handlers = new Set();
862    }
863

Base, empty implementation of #summarize() which is overridden in all subclasses. In subclasses, this returns the "summary" of the current state of the event emitter as an object/array.

867    summarize /* istanbul ignore next */ () {}
868

Whenever something changes, we fire an event to all subscribed listeners, with a summary of its state.

871    emitEvent() {
872        const summary = this.summarize();
873        for (const handler of this.handlers) {
874            handler(summary);
875        }
876    }
877
878    addHandler(handler) {
879        this.handlers.add(handler);
880        handler(this.summarize());
881    }
882
883    removeHandler(handler) {
884        this.handlers.delete(handler);
885    }
886
887}
888

Record is Torus's unit of individual data source, used for view models and Models from business logic.

891class Record extends Evented {
892
893    constructor(id, data = {}) {
894        super();
895

We can create a Record by either passing in just the properties, or an ID and a dictionary of props. We disambiguate here.

898        if (isObject(id)) {
899            data = id;
900            id = null;
901        }
902
903        this.id = id;
904        this.data = data;
905    }
906

Setter for properties

908    update(data) {
909        Object.assign(this.data, data);
910        this.emitEvent();
911    }
912

Getter

914    get(name) {
915        return this.data[name];
916    }
917

We summarize a Record by returning a dictionary of all of its properties and the ID

920    summarize() {
921        return Object.assign(
922            {id: this.id},
923            this.data
924        );
925    }
926

The JSON-serialized version of a Record is the same as its summary, since it's a shallow data store with just plain properties.

929    serialize() {
930        return this.summarize();
931    }
932
933}
934

A list of Records, represents a collection or a table

936class Store extends Evented {
937
938    constructor(records = []) {
939        super();

Reset the store's contents with the given records

941        this.reset(records);
942    }
943
944    get recordClass() {
945        return Record;
946    }
947
948    get comparator() {
949        return null;
950    }
951

Create and return a new instance of the store's record from the given data.

954    create(id, data) {
955        return this.add(new this.recordClass(id, data));
956    }
957

Add a given record to this store, also called by #create().

959    add(record) {
960        this.records.add(record);
961        this.emitEvent();
962        return record;
963    }
964

Remove a given record from the store.

966    remove(record) {
967        this.records.delete(record);
968        this.emitEvent();
969        return record;
970    }
971

This iterator is called when JavaScript requests an iterator from a store, like when for (const _ of someStore) is run.

974    [Symbol.iterator]() {
975        return this.records.values();
976    }
977

Try to find a record with the given ID in the store, and return it. Returns null if not found.

980    find(id) {
981        for (const record of this.records) {
982            if (record.id === id) {
983                return record;
984            }
985        }
986        return null;
987    }
988
989    reset(records) {

Internally, we represent the store as an unordered set. we only order by comparator when we summarize. This prevents us from having to perform sorting checks on every insert/update, and is efficient as long as we don't re-render excessively.

994        this.records = new Set(records);
995        this.emitEvent();
996    }
997
998    summarize() {

The summary of a store is defined functionally. We just sort the records in our store by the comparator (but we use a list of pairs of cached comparators and records to be fast.

1002        return [...this.records].map(record => [
1003            this.comparator ? this.comparator(record) : null,
1004            record,
1005        ]).sort((a, b) => {
1006            if (a[0] < b[0]) {
1007                return -1;
1008            } else if (a[0] > b[0]) {
1009                return 1;
1010            } else {
1011                return 0;
1012            }
1013        }).map(o => o[1]);
1014    }
1015

To serialize a store, we serialize each record and put them in a giant list.

1018    serialize() {
1019        return this.summarize().map(record => record.serialize());
1020    }
1021
1022}
1023

Higher-order component to create a Store for a given record class.

1026const StoreOf = recordClass => {
1027    return class extends Store {
1028        get recordClass() {
1029            return recordClass;
1030        }
1031    }
1032}
1033

Helper function for the router. It takes a route string that contains parameters like, /path/:param1/path/:param2 and returns a regular expression to match that route and a list of params in that route.

1038const routeStringToRegExp = route => {
1039    let match;
1040    const paramNames = [];
1041    while (match !== null) {
1042        match = (/:\w+/).exec(route);
1043        if (match) {
1044            const paramName = match[0];
1045            paramNames.push(paramName.substr(1));
1046            route = route.replace(paramName, '(.+)');
1047        }
1048    }
1049    return [new RegExp(route), paramNames];
1050}
1051

Front-end router. A routing component can bind to updates from the Router instead of a Record, and re-render different subviews when the routes change.

1055class Router extends Evented {
1056
1057    constructor(routes) {
1058        super();

We parse the given dictionary of routes into three things: the name of the route, the route regular expression, and the list of params in that route.

1062        this.routes = Object.entries(routes)
1063            .map(([name, route]) => [name, ...routeStringToRegExp(route)]);

Last matched route's information is cached here

1065        this.lastMatch = ['', null];

Whenever the browser pops the history state (i.e. when the user goes back with the back button or forward with the forward button), we need to route again.

1069        this._cb = () => this.route(location.pathname);
1070        window.addEventListener('popstate', this._cb);

Route the current URL, if it's already a deep link to a path.

1072        this._cb();
1073    }
1074

The "summary" of this Evented (components can bind to this object) is the information about the last route.

1077    summarize() {
1078        return this.lastMatch;
1079    }
1080

Click events from links can call this.go() with the destination URL to trigger going to a new route without reloading the page. New routes are only added to the session history if the route is indeed new.

1084    go(destination, {replace = false} = {}) {
1085        if (window.location.pathname !== destination) {
1086            if (replace) {
1087                history.replaceState(null, document.title, destination);
1088            } else {
1089                history.pushState(null, document.title, destination);
1090            }
1091            this.route(destination);
1092        }
1093    }
1094

Main procedure to reconcile which of the defined route the current location path matches, and dispatch the right event. Routes are checked in order of declaration.

1098    route(path) {

Match destination against the route regular expressions

1100        for (const [name, routeRe, paramNames] of this.routes) {
1101            const match = routeRe.exec(path);
1102            if (match !== null) {
1103                const result = {};
1104                const paramValues = match.slice(1);

Given the matched values and parameter names, build a dictionary of params that components can use to re-render based on the route.

1108                paramNames.forEach((name, i) => result[name] = paramValues[i]);
1109                this.lastMatch = [name, result];
1110                break;
1111            }
1112        }
1113        this.emitEvent();
1114    }
1115

When we don't want the router to work anymore / stop listening / be gc'd, we can call #remove() to do just that.

1118    remove() {
1119        window.removeEventListener('popstate', this._cb);
1120    }
1121
1122}
1123

Torus exposes these public APIs

1125const exposedNames = {

render isn't designed to be a public API and the API might change, but it's exposed to make unit testing easier.

1128    render,
1129    Component,
1130    Styled,

Provide a default, StyledComponent class.

1132    StyledComponent: Styled(Component),
1133    List,
1134    ListOf,
1135    Record,
1136    Store,
1137    StoreOf,
1138    Router,
1139}
1140

If there is a global window object, bind API names to it.

1142/* istanbul ignore else */
1143if (typeof window === 'object') {
1144    window.Torus = exposedNames;
1145}

Export public APIs CommonJS-style

1147/* istanbul ignore next */
1148if (typeof module === 'object' && module.exports) {
1149    module.exports = exposedNames;
1150}
1151