Skip to content

Commit

Permalink
feat(hdom): add DOM hydration support (SSR), update start() (#39)
Browse files Browse the repository at this point in the history
- add HDOMOpts interface
- add hydrateDOM()
- update start() to support hydration
- re-use migrated NO_SPANS const from hiccup
- switch all fn to arrow fns

BREAKING CHANGE: start() args now as options object
  • Loading branch information
postspectacular committed Aug 31, 2018
1 parent 1b97a25 commit 9f8010d
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 58 deletions.
26 changes: 26 additions & 0 deletions packages/hdom/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,30 @@ export interface ComponentAttribs {
[_: string]: any;
}

export interface HDOMOpts {
/**
* Root element or ID
*/
parent: Element | string;
/**
* Arbitrary user context object
*/
ctx?: any;
/**
* If true (default), text content will be wrapped in `<span>`
*/
span?: boolean;
/**
* If true (default false), the first frame will only be used to
* inject event listeners.
*
* *Important:* Enabling this option assumes that an equivalent DOM
* (minus listeners) already exists (i.e. generated via SSR) when
* hdom's `start()` function is called. Any other discrepancies
* between the pre-existing DOM and the hdom trees will cause
* undefined behavior.
*/
hydrate?: boolean;
}

export const DEBUG = false;
19 changes: 9 additions & 10 deletions packages/hdom/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,10 @@ const isString = iss.isString;
* @param prev previous tree
* @param curr current tree
*/
export function diffElement(root: Element, prev: any, curr: any) {
export const diffElement = (root: Element, prev: any, curr: any) =>
_diffElement(root, prev, curr, 0);
}

function _diffElement(parent: Element, prev: any, curr: any, child: number) {
const _diffElement = (parent: Element, prev: any, curr: any, child: number) => {
const delta = diffArray(prev, curr, equiv, true);
if (delta.distance === 0) {
return;
Expand Down Expand Up @@ -109,9 +108,9 @@ function _diffElement(parent: Element, prev: any, curr: any, child: number) {
// DEBUG && console.log("call __init", curr);
i.apply(curr, [el, ...(curr.__args)]);
}
}
};

function releaseDeep(tag: any) {
const releaseDeep = (tag: any) => {
if (isArray(tag)) {
if ((<any>tag).__release) {
// DEBUG && console.log("call __release", tag);
Expand All @@ -122,9 +121,9 @@ function releaseDeep(tag: any) {
releaseDeep(tag[i]);
}
}
}
};

function diffAttributes(el: Element, prev: any, curr: any) {
const diffAttributes = (el: Element, prev: any, curr: any) => {
let i, e, edits;
const delta = diffObject(prev, curr);
removeAttribs(el, delta.dels, prev);
Expand Down Expand Up @@ -152,9 +151,9 @@ function diffAttributes(el: Element, prev: any, curr: any) {
if (value !== SEMAPHORE) {
setAttrib(el, "value", value, curr);
}
}
};

function extractEquivElements(edits: DiffLogEntry<any>[]) {
const extractEquivElements = (edits: DiffLogEntry<any>[]) => {
let k, v, e, ek;
const equiv = {};
for (let i = edits.length; --i >= 0;) {
Expand All @@ -167,4 +166,4 @@ function extractEquivElements(edits: DiffLogEntry<any>[]) {
}
}
return equiv;
}
};
71 changes: 47 additions & 24 deletions packages/hdom/src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const isString = iss.isString;
* @param tag
* @param insert
*/
export function createDOM(parent: Element, tag: any, insert?: number) {
export const createDOM = (parent: Element, tag: any, insert?: number) => {
if (isArray(tag)) {
const t = tag[0];
if (isFunction(t)) {
Expand Down Expand Up @@ -52,9 +52,35 @@ export function createDOM(parent: Element, tag: any, insert?: number) {
return parent;
}
return createTextElement(parent, tag);
}
};

export function createElement(parent: Element, tag: string, attribs?: any, insert?: number) {
export const hydrateDOM = (parent: Element, tree: any, i = 0) => {
if (isArray(tree)) {
const el = parent.children[i];
if (isFunction(tree[0])) {
return hydrateDOM(parent, tree[0].apply(null, tree.slice(1)), i);
}
if ((<any>tree).__init) {
(<any>tree).__init.apply((<any>tree).__this, [el, ...(<any>tree).__args]);
}
const attr = tree[1];
for (let a in attr) {
if (a.indexOf("on") === 0) {
el.addEventListener(a.substr(2), attr[a]);
}
}
for (let n = tree.length, i = 2; i < n; i++) {
hydrateDOM(el, tree[i], i - 2);
}
} else if (!isString(tree) && isIterable(tree)) {
for (let t of tree) {
hydrateDOM(parent, t, i);
i++;
}
}
};

export const createElement = (parent: Element, tag: string, attribs?: any, insert?: number) => {
const el = SVG_TAGS[tag] ?
document.createElementNS(SVG_NS, tag) :
document.createElement(tag);
Expand All @@ -69,9 +95,9 @@ export function createElement(parent: Element, tag: string, attribs?: any, inser
setAttribs(el, attribs);
}
return el;
}
};

export function createTextElement(parent: Element, content: string, insert?: number) {
export const createTextElement = (parent: Element, content: string, insert?: number) => {
const el = document.createTextNode(content);
if (parent) {
if (insert === undefined) {
Expand All @@ -81,21 +107,21 @@ export function createTextElement(parent: Element, content: string, insert?: num
}
}
return el;
}
};

export function cloneWithNewAttribs(el: Element, attribs: any) {
export const cloneWithNewAttribs = (el: Element, attribs: any) => {
const res = <Element>el.cloneNode(true);
setAttribs(res, attribs);
el.parentNode.replaceChild(res, el);
return res;
}
};

export function setAttribs(el: Element, attribs: any) {
export const setAttribs = (el: Element, attribs: any) => {
for (let k in attribs) {
setAttrib(el, k, attribs[k], attribs);
}
return el;
}
};

/**
* Sets a single attribute on given element. If attrib name is NOT
Expand All @@ -118,7 +144,7 @@ export function setAttribs(el: Element, attribs: any) {
* @param val
* @param attribs
*/
export function setAttrib(el: Element, id: string, val: any, attribs?: any) {
export const setAttrib = (el: Element, id: string, val: any, attribs?: any) => {
const isListener = id.indexOf("on") === 0;
if (!isListener && isFunction(val)) {
val = val(attribs);
Expand Down Expand Up @@ -146,9 +172,9 @@ export function setAttrib(el: Element, id: string, val: any, attribs?: any) {
el[id] != null ? (el[id] = null) : el.removeAttribute(id);
}
return el;
}
};

export function updateValueAttrib(el: HTMLInputElement, v: any) {
export const updateValueAttrib = (el: HTMLInputElement, v: any) => {
switch (el.type) {
case "text":
case "textarea":
Expand All @@ -166,9 +192,9 @@ export function updateValueAttrib(el: HTMLInputElement, v: any) {
default:
el.value = v;
}
}
};

export function removeAttribs(el: Element, attribs: string[], prev: any) {
export const removeAttribs = (el: Element, attribs: string[], prev: any) => {
for (let i = attribs.length; --i >= 0;) {
const a = attribs[i];
if (a.indexOf("on") === 0) {
Expand All @@ -177,20 +203,17 @@ export function removeAttribs(el: Element, attribs: string[], prev: any) {
el[a] ? (el[a] = null) : el.removeAttribute(a);
}
}
}
};

export function setStyle(el: Element, styles: any) {
el.setAttribute("style", css(styles));
return el;
}
export const setStyle = (el: Element, styles: any) =>
(el.setAttribute("style", css(styles)), el);

export function clearDOM(el: Element) {
export const clearDOM = (el: Element) =>
el.innerHTML = "";
}

export function removeChild(parent: Element, childIdx: number) {
export const removeChild = (parent: Element, childIdx: number) => {
const n = parent.children[childIdx];
if (n !== undefined) {
n.remove();
}
}
};
14 changes: 4 additions & 10 deletions packages/hdom/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as isi from "@thi.ng/checks/is-iterable";
import * as iso from "@thi.ng/checks/is-plain-object";
import * as iss from "@thi.ng/checks/is-string";
import { illegalArgs } from "@thi.ng/errors/illegal-arguments";
import { TAG_REGEXP } from "@thi.ng/hiccup/api";
import { NO_SPANS, TAG_REGEXP } from "@thi.ng/hiccup/api";

const isArray = isa.isArray;
const isFunction = isf.isFunction;
Expand Down Expand Up @@ -38,7 +38,7 @@ const isString = iss.isString;
* @param spec
* @param keys
*/
export function normalizeElement(spec: any[], keys: boolean) {
export const normalizeElement = (spec: any[], keys: boolean) => {
let tag = spec[0], hasAttribs = isPlainObject(spec[1]), match, id, clazz, attribs;
if (!isString(tag) || !(match = TAG_REGEXP.exec(tag))) {
illegalArgs(`${tag} is not a valid tag name`);
Expand All @@ -62,12 +62,6 @@ export function normalizeElement(spec: any[], keys: boolean) {
}
}
return [match[1], attribs, ...spec.slice(hasAttribs ? 2 : 1)];
}

const NO_SPANS = {
option: 1,
text: 1,
textarea: 1,
};

/**
Expand Down Expand Up @@ -111,7 +105,7 @@ const NO_SPANS = {
* @param keys
* @param span
*/
export function normalizeTree(tree: any, ctx?: any, path = [0], keys = true, span = true) {
export const normalizeTree = (tree: any, ctx?: any, path = [0], keys = true, span = true) => {
if (tree == null) {
return;
}
Expand Down Expand Up @@ -182,4 +176,4 @@ export function normalizeTree(tree: any, ctx?: any, path = [0], keys = true, spa
return span ?
["span", keys ? { key: path.join("-") } : {}, tree.toString()] :
tree.toString();
}
};
41 changes: 27 additions & 14 deletions packages/hdom/src/start.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { isString } from "@thi.ng/checks/is-string";

import { HDOMOpts } from "./api";
import { diffElement } from "./diff";
import { normalizeTree } from "./normalize";
import { hydrateDOM } from "@thi.ng/hdom/src/dom";

/**
* Takes a parent DOM element (or ID), hiccup tree (array, function or
Expand All @@ -23,30 +26,40 @@ import { normalizeTree } from "./normalize";
* which then is diffed and applied against the previous tree kept as
* usual. Any number of frames may be skipped this way.
*
* Important: The parent element given is assumed to have NO children at
* the time when `start()` is called. Since hdom does NOT track the real
* DOM, the resulting changes will result in potentially undefined
* behavior if the parent element wasn't empty.
* **Important:** Unless the `hydrate` option is enabled, the parent
* element given is assumed to have NO children at the time when
* `start()` is called. Since hdom does NOT track the real DOM, the
* resulting changes will result in potentially undefined behavior if
* the parent element wasn't empty. Likewise, if `hydrate` is enabled,
* it is assumed that an equivalent DOM (minus listeners) already exists
* (i.e. generated via SSR) when `start()` is called. Any other
* discrepancies between the pre-existing DOM and the hdom trees will
* cause undefined behavior.
*
* Returns a function, which when called, immediately cancels the update
* loop.
*
* @param parent root element or ID
* @param tree hiccup DOM tree
* @param ctx arbitrary user context object
* @param spans true (default), if text should be wrapped in `<span>`
* @param opts options
*/
export function start(parent: Element | string, tree: any, ctx?: any, spans = true) {
export const start = (tree: any, opts: HDOMOpts) => {
let prev = [];
let isActive = true;
parent = isString(parent) ?
document.getElementById(parent) :
parent;
let hydrate = opts.hydrate;
const spans = opts.span !== false;
const root = isString(opts.parent) ?
document.getElementById(opts.parent) :
opts.parent;
function update() {
if (isActive) {
const curr = normalizeTree(tree, ctx, [0], true, spans);
const curr = normalizeTree(tree, opts.ctx, [0], true, spans);
if (curr != null) {
diffElement(<Element>parent, prev, curr);
if (hydrate) {
hydrateDOM(root, curr);
hydrate = false;
} else {
diffElement(root, prev, curr);
}
prev = curr;
}
// check again in case one of the components called cancel
Expand All @@ -55,4 +68,4 @@ export function start(parent: Element | string, tree: any, ctx?: any, spans = tr
}
requestAnimationFrame(update);
return () => (isActive = false);
}
};

0 comments on commit 9f8010d

Please sign in to comment.