torus.js annotated source
Back to index1// @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