Skip to content

Commit e65a2bc

Browse files
committed
Back to the chalk board
This MR fixes #102 and fixes #103 + it provides further hydration hints out of the box. Current changes: * each fagment is demilited by `<>` and `</>` comments: see notes * this is a linear render: values are never looped more than once * this version of *uhtml* is even more Memory friendly: a lot has been refactored to consume and recycle as much as possible * the fragment in fragment issue has been resolved * the array hole in tags has been converted into a fragment case * the PersistentFragment has been refactored to survive edge cases * the performance is either better or the same as before * the Array hole now is a `<!--[N]-->` comment where `N` is the amount of nodes handled * holes are still transparent so that the amount of nodes is still ideal * a new code coverage goal has been reached: 100% of everything, including uhtml/dom * a new test has been written to help out with expectations on the DOM world (browsers) as well as SSR * the SSR story is still to be defined but everything is coming out nicely ... there are fragment hints, array hints, only missing hints to produce a DOM to Template transformer are holes which might land on SSR version only, as it would be ugly to have so many comments in the wild for no reason
1 parent 6920bd5 commit e65a2bc

18 files changed

+469
-166
lines changed

esm/creator.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ const childNodesIndex = (node, i) => node.childNodes[i];
1414
export default parse => (
1515
/** @param {(template: TemplateStringsArray, values: any[]) => import("./literals.js").Parsed} parse */
1616
(template, values) => {
17-
const { c: content, e: entries, l: length } = parse(template, values);
18-
const root = content.cloneNode(true);
17+
const { f: fragment, e: entries, d: direct } = parse(template, values);
18+
const root = fragment.cloneNode(true);
1919
let current, prev, details = entries === empty ? empty : [];
2020
for (let i = 0; i < entries.length; i++) {
2121
const { p: path, u: update, n: name } = entries[i];
2222
const node = path === prev ? current : (current = find(root, (prev = path)));
2323
details[i] = detail(empty, update, node, name);
2424
}
2525
return parsed(
26-
length === 1 ? root.firstChild : new PersistentFragment(root),
26+
direct ? root.firstChild : new PersistentFragment(root),
2727
details
2828
);
2929
}

esm/handler.js

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import drop from './range.js';
66
const setAttribute = (element, name, value) =>
77
element.setAttribute(name, value);
88

9+
/**
10+
* @param {Element} element
11+
* @param {string} name
12+
* @returns {void}
13+
*/
914
export const removeAttribute = (element, name) =>
1015
element.removeAttribute(name);
1116

@@ -51,22 +56,29 @@ const holes = new WeakMap;
5156

5257
/**
5358
* @template T
59+
* @this {import("./literals.js").Detail}
5460
* @param {Node} node
5561
* @param {T} value
5662
* @returns {T}
5763
*/
58-
export const hole = (node, value) => {
59-
const h = holes.get(node);
60-
if (h) h.remove();
61-
let nullish = value == null;
62-
if (nullish || typeof value !== 'object') {
63-
if (h) holes.delete(node);
64-
}
65-
else {
66-
nullish = true;
67-
node.before(set(holes, node, value.valueOf()));
64+
export function hole(node, value) {
65+
let { n: hole } = this, nullish = false;
66+
switch (typeof value) {
67+
case 'object':
68+
if (value !== null) {
69+
(hole || node).replaceWith((this.n = value.valueOf()));
70+
break;
71+
}
72+
case 'undefined':
73+
nullish = true;
74+
default:
75+
node.data = nullish ? '' : value;
76+
if (hole) {
77+
this.n = null;
78+
hole.replaceWith(node);
79+
}
80+
break;
6881
}
69-
node.data = nullish ? '' : value;
7082
return value;
7183
};
7284

@@ -183,21 +195,22 @@ export const toggle = (element, value, name) => (
183195
* @param {Node[]} prev
184196
* @returns {Node[]}
185197
*/
186-
export const array = (node, value, _, prev) => {
198+
export const array = (node, value, prev) => {
187199
// normal diff
188-
if (value.length)
200+
const { length } = value;
201+
node.data = `[${length}]`;
202+
if (length)
189203
return udomdiff(node.parentNode, prev, value, diffFragment, node);
190204
/* c8 ignore start */
191-
const { length } = prev;
192-
switch (length) {
205+
switch (prev.length) {
193206
case 1:
194207
prev[0].remove();
195208
case 0:
196209
break;
197210
default:
198211
drop(
199212
diffFragment(prev[0], 0),
200-
diffFragment(prev[length - 1], -0),
213+
diffFragment(prev.at(-1), -0),
201214
false
202215
);
203216
break;

esm/literals.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import { empty } from './utils.js';
1515
*/
1616

1717
/**
18-
* @param {PersistentFragment} c content retrieved from the template
18+
* @param {DocumentFragment} f content retrieved from the template
1919
* @param {Entry[]} e entries per each hole in the template
20-
* @param {number} l the length of content childNodes
20+
* @param {boolean} d direct node to handle
2121
* @returns
2222
*/
23-
export const cel = (c, e, l) => ({ c, e, l });
23+
export const cel = (f, e, d) => ({ f, e, d });
2424

2525
/**
2626
* @typedef {Object} Detail
@@ -34,18 +34,18 @@ export const cel = (c, e, l) => ({ c, e, l });
3434
* @param {any} v the current value of the interpolation / hole
3535
* @param {function} u the callback to update the value
3636
* @param {Node} t the target comment node or element
37-
* @param {string} n the name of the attribute, if any
37+
* @param {string?} n the attribute name, if any, or `null`
3838
* @returns {Detail}
3939
*/
4040
export const detail = (v, u, t, n) => ({ v, u, t, n });
4141

4242
/**
4343
* @param {number[]} p the path to retrieve the node
4444
* @param {function} u the update function
45-
* @param {string} n the attribute name, if any
45+
* @param {string?} n the attribute name, if any, or `null`
4646
* @returns {Entry}
4747
*/
48-
export const entry = (p, u, n = '') => ({ p, u, n });
48+
export const entry = (p, u, n) => ({ p, u, n });
4949

5050
/**
5151
* @typedef {Object} Cache

esm/parser.js

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { COMMENT_NODE } from 'domconstants/constants';
1+
import { COMMENT_NODE, ELEMENT_NODE } from 'domconstants/constants';
22
import { TEXT_ELEMENTS } from 'domconstants/re';
33
import parser from '@webreflection/uparser';
44

@@ -32,6 +32,8 @@ const createPath = node => {
3232
return path;
3333
};
3434

35+
const textNode = () => document.createTextNode('');
36+
3537
/**
3638
* @param {TemplateStringsArray} template
3739
* @param {boolean} xml
@@ -40,47 +42,74 @@ const createPath = node => {
4042
const resolve = (template, values, xml) => {
4143
const content = createContent(parser(template, prefix, xml), xml);
4244
const { length } = template;
43-
let asArray = false, entries = empty;
45+
let entries = empty;
4446
if (length > 1) {
4547
const replace = [];
4648
const tw = document.createTreeWalker(content, 1 | 128);
4749
let i = 0, search = `${prefix}${i++}`;
4850
entries = [];
4951
while (i < length) {
5052
const node = tw.nextNode();
53+
// these are holes or arrays
5154
if (node.nodeType === COMMENT_NODE) {
5255
if (node.data === search) {
56+
// ⚠️ once array, always array!
5357
const update = isArray(values[i - 1]) ? array : hole;
5458
if (update === hole) replace.push(node);
55-
else asArray = true;
56-
entries.push(entry(createPath(node), update));
59+
else node.data = '[]';
60+
entries.push(entry(createPath(node), update, null));
5761
search = `${prefix}${i++}`;
5862
}
5963
}
6064
else {
6165
let path;
66+
// these are attributes
6267
while (node.hasAttribute(search)) {
6368
if (!path) path = createPath(node);
6469
const name = node.getAttribute(search);
6570
entries.push(entry(path, attribute(node, name, xml), name));
6671
removeAttribute(node, search);
6772
search = `${prefix}${i++}`;
6873
}
74+
// these are special text-only nodes
6975
if (
76+
!xml &&
7077
TEXT_ELEMENTS.test(node.localName) &&
7178
node.textContent.trim() === `<!--${search}-->`
7279
) {
73-
entries.push(entry(path || createPath(node), text));
80+
entries.push(entry(path || createPath(node), text, null));
7481
search = `${prefix}${i++}`;
7582
}
7683
}
7784
}
7885
// can't replace holes on the fly or the tree walker fails
7986
for (i = 0; i < replace.length; i++)
80-
replace[i].replaceWith(document.createTextNode(''));
87+
replace[i].replaceWith(textNode());
88+
}
89+
90+
// need to decide if there should be a persistent fragment
91+
const { childNodes } = content;
92+
let { length: len } = childNodes;
93+
94+
// html`` or svg`` to signal an empty content
95+
// these nodes can be passed directly as never mutated
96+
if (len < 1) {
97+
len = 1;
98+
content.appendChild(textNode());
99+
}
100+
// html`${'b'}` or svg`${[]}` cases
101+
else if (
102+
len === 1 &&
103+
// ignore html`static` or svg`static` because
104+
// these nodes can be passed directly as never mutated
105+
length !== 1 &&
106+
childNodes[0].nodeType !== ELEMENT_NODE
107+
) {
108+
// use a persistent fragment for these cases too
109+
len = 0;
81110
}
82-
const l = content.childNodes.length;
83-
return set(cache, template, cel(content, entries, l === 1 && asArray ? 0 : l));
111+
112+
return set(cache, template, cel(content, entries, len === 1));
84113
};
85114

86115
/** @type {WeakMap<TemplateStringsArray, Resolved>} */

esm/persistent-fragment.js

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DOCUMENT_FRAGMENT_NODE } from 'domconstants/constants';
22
import custom from 'custom-function/factory';
33
import drop from './range.js';
4+
import { empty } from './utils.js';
45

56
/**
67
* @param {PersistentFragment} fragment
@@ -23,29 +24,54 @@ export const diffFragment = (node, operation) => (
2324
node
2425
);
2526

27+
const comment = value => document.createComment(value);
28+
2629
/** @extends {DocumentFragment} */
2730
export class PersistentFragment extends custom(DocumentFragment) {
28-
#nodes;
29-
#length;
31+
#firstChild = comment('<>');
32+
#lastChild = comment('</>');
33+
#nodes = empty;
3034
constructor(fragment) {
31-
const _nodes = [...fragment.childNodes];
3235
super(fragment);
33-
this.#nodes = _nodes;
34-
this.#length = _nodes.length;
36+
this.replaceChildren(...[
37+
this.#firstChild,
38+
...fragment.childNodes,
39+
this.#lastChild,
40+
]);
3541
checkType = true;
3642
}
37-
get firstChild() { return this.#nodes[0]; }
38-
get lastChild() { return this.#nodes.at(-1); }
39-
get parentNode() { return this.#nodes[0].parentNode; }
40-
/* c8 ignore start */
41-
remove() { remove(this, false); }
42-
/* c8 ignore stop */
43+
get firstChild() { return this.#firstChild; }
44+
get lastChild() { return this.#lastChild; }
45+
get parentNode() { return this.#firstChild.parentNode; }
46+
remove() {
47+
remove(this, false);
48+
}
4349
replaceWith(node) {
4450
remove(this, true).replaceWith(node);
4551
}
4652
valueOf() {
47-
if (this.childNodes.length < this.#length)
53+
let { firstChild, lastChild, parentNode } = this;
54+
if (parentNode === this) {
55+
if (this.#nodes === empty)
56+
this.#nodes = [...this.childNodes];
57+
}
58+
else {
59+
/* c8 ignore start */
60+
// there are cases where a fragment might be just appended
61+
// out of the box without valueOf() invoke (first render).
62+
// When these are moved around and lose their parent and,
63+
// such parent is not the fragment itself, it's possible there
64+
// where changes or mutations in there to take care about.
65+
// This is a render-only specific issue but it's tested and
66+
// it's worth fixing to me to have more consistent fragments.
67+
if (parentNode) {
68+
this.#nodes = [firstChild];
69+
while (firstChild !== lastChild)
70+
this.#nodes.push((firstChild = firstChild.nextSibling));
71+
}
72+
/* c8 ignore stop */
4873
this.replaceChildren(...this.#nodes);
74+
}
4975
return this;
5076
}
5177
}

esm/rabbit.js

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import parser from './parser.js';
77
const parseHTML = create(parser(false));
88
const parseSVG = create(parser(true));
99

10+
const createCache = ({ u }) => (
11+
u === array ?
12+
newCache([]) : (
13+
u === hole ?
14+
newCache(empty) :
15+
null
16+
)
17+
);
18+
1019
/**
1120
* @param {import("./literals.js").Cache} cache
1221
* @param {Hole} hole
@@ -19,25 +28,33 @@ export const unroll = (cache, { s, t, v }) => {
1928
cache.t = t;
2029
cache.n = n;
2130
cache.d = (details = d);
22-
if (v.length) cache.s = (stack = []);
31+
if (v.length) cache.s = (stack = d.map(createCache));
2332
}
2433
for (; i < details.length; i++) {
2534
const value = v[i];
2635
const detail = details[i];
2736
const { v: previous, u: update, t: target, n: name } = detail;
28-
const asArray = update === array;
29-
const asHole = !asArray && update === hole;
30-
const cache = stack[i] || (
31-
stack[i] = asArray ?
32-
newCache([]) :
33-
(asHole ? newCache(empty) : null)
34-
);
35-
const current = asArray ?
36-
unrollValues(cache, value) :
37-
(asHole ? (value instanceof Hole ? unroll(cache, value) : value) : value)
38-
;
39-
if (asArray || (current !== previous))
40-
detail.v = update(target, current, name, previous);
37+
switch (update) {
38+
case array:
39+
detail.v = array(
40+
target,
41+
unrollValues(stack[i], value),
42+
previous
43+
);
44+
break;
45+
case hole:
46+
const current = value instanceof Hole ?
47+
unroll(stack[i], value) :
48+
value
49+
;
50+
if (current !== previous)
51+
detail.v = hole.call(detail, target, current);
52+
break;
53+
default:
54+
if (value !== previous)
55+
detail.v = update(target, value, name, previous);
56+
break;
57+
}
4158
}
4259
return cache.n;
4360
};

esm/render/hole.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ const known = new WeakMap;
1717
export default (where, what) => {
1818
const info = known.get(where) || set(known, where, cache(empty));
1919
if (info.n !== unroll(info, typeof what === 'function' ? what() : what))
20-
where.replaceChildren(info.n);
20+
where.replaceChildren(info.n.valueOf());
2121
return where;
2222
};

esm/render/node.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
* @returns
99
*/
1010
export default (where, what) => {
11-
where.replaceChildren(typeof what === 'function' ? what() : what);
11+
where.replaceChildren(
12+
(typeof what === 'function' ? what() : what).valueOf()
13+
);
1214
return where;
1315
};

0 commit comments

Comments
 (0)