Skip to content

Commit

Permalink
up to the point I need to reverse-loop the container to have clear pa…
Browse files Browse the repository at this point in the history
…ths out of the template
  • Loading branch information
WebReflection committed Apr 13, 2024
1 parent 94bb3db commit ed8e7f9
Show file tree
Hide file tree
Showing 13 changed files with 220 additions and 34 deletions.
3 changes: 2 additions & 1 deletion esm/dom/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { DOCUMENT_NODE } from 'domconstants/constants';

import { setParentNode } from './utils.js';

import { childNodes, documentElement, nodeName, ownerDocument } from './symbols.js';
import { childNodes, documentElement, nodeName, ownerDocument, __chunks__ } from './symbols.js';

import Attribute from './attribute.js';
import Comment from './comment.js';
Expand Down Expand Up @@ -33,6 +33,7 @@ export default class Document extends Parent {
this[doctype] = null;
this[head] = null;
this[body] = null;
this[__chunks__] = false;
if (type === 'html') {
const html = (this[documentElement] = new Element(type, this));
this[childNodes] = [
Expand Down
1 change: 1 addition & 0 deletions esm/dom/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export const parentNode = Symbol('parentNode');
export const attributes = Symbol('attributes');
export const name = Symbol('name');
export const value = Symbol('value');
export const __chunks__ = Symbol();
8 changes: 6 additions & 2 deletions esm/dom/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TEXT_ELEMENTS } from 'domconstants/re';
import { escape } from 'html-escaper';

import CharacterData from './character-data.js';
import { parentNode, localName, ownerDocument, value } from './symbols.js';
import { parentNode, localName, ownerDocument, value, __chunks__ } from './symbols.js';

export default class Text extends CharacterData {
constructor(data = '', owner = null) {
Expand All @@ -17,6 +17,10 @@ export default class Text extends CharacterData {
toString() {
const { [parentNode]: parent, [value]: data } = this;
return parent && TEXT_ELEMENTS.test(parent[localName]) ?
data : escape(data);
data :
(this[ownerDocument]?.[__chunks__] && this.previousSibling?.nodeType === TEXT_NODE ?
`<!--#-->${escape(data)}` :
escape(data)
);
}
}
78 changes: 66 additions & 12 deletions esm/hydro.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PersistentFragment } from './persistent-fragment.js';
import { COMMENT_NODE, TEXT_NODE } from 'domconstants/constants';
import { abc, cache, detail } from './literals.js';
import { empty, find, set } from './utils.js';
import { array, hole } from './handler.js';
Expand All @@ -14,24 +14,79 @@ import {
const parseHTML = parse(false, true);
const parseSVG = parse(true, true);

const hydrate = (fragment, {s, t, v}) => {
const parent = () => ({ childNodes: [] });

const skip = (node, data) => {

};

const reMap = (parentNode, { childNodes }) => {
for (let first = true, { length } = childNodes; length--;) {
let node = childNodes[length];
switch (node.nodeType) {
case COMMENT_NODE:
if (node.data === '</>') {
let nested = 0;
while (node = node.previousSibling) {
length--;
if (node.nodeType === COMMENT_NODE) {
if (node.data === '</>') nested++;
else if (node.data === '<>') {
if (!nested--) break;
}
}
else
parentNode.childNodes.unshift(node);
}
}
else if (/\[(\d+)\]/.test(node.data)) {
let many = +RegExp.$1;
parentNode.childNodes.unshift(node);
while (many--) {
node = node.previousSibling;
if (node.nodeType === COMMENT_NODE && node.data === '}') {
node = skip(node, '{');
}
}
}
break;
case TEXT_NODE:
// ignore browser artifacts on closing fragments
if (first && !node.data.trim()) break;
default:
parentNode.childNodes.unshift(node);
break;
}
first = false;
}
return parentNode;
};

const hydrate = (root, {s, t, v}) => {
debugger;
const { b: entries, c: direct } = (s ? parseSVG : parseHTML)(t, v);
const { length } = entries;
if (length !== v.length) return noHydration;
let root = fragment, details = length ? [] : empty;
if (!direct) {
if (
fragment.firstChild?.data !== '<>' ||
fragment.lastChild?.data !== '</>'
) return noHydration;
root = PersistentFragment.adopt(fragment);
}
// let's assume hydro is used on purpose with valid templates
// to use entries meaningfully re-map the container.
// This is complicated yet possible.
// * fragments are allowed only top-level
// * nested fragments will likely be wrapped in holes
// * arrays can point at either fragments, DOM nodes, or holes
// * arrays can't be path-addressed if not for the comment itself
// * ideally their previous content should be pre-populated with nodes, holes and fragments
// * it is possible that the whole dance is inside-out so that nested normalized content
// can be then addressed (as already live) by the outer content
const fake = reMap(parent(), root, direct);
const details = length ? [] : empty;
for (let current, prev, i = 0; i < length; i++) {
const { a: path, b: update, c: name } = entries[i];
// adjust the length of the first path node
if (!direct) path[path.length - 1]++;
// TODO: node should be adjusted if it's array or hole
// * if it's array, no way caching it as current helps
// * if it's a hole or attribute/text thing, current helps
let node = path === prev ? current : (current = find(root, (prev = path)));
if (!direct) path[path.length - 1]--;
details[i] = detail(
update,
node,
Expand All @@ -45,7 +100,6 @@ const hydrate = (fragment, {s, t, v}) => {
};

const known = new WeakMap;
const noHydration = cache();

const render = (where, what) => {
const hole = typeof what === 'function' ? what() : what;
Expand Down
13 changes: 11 additions & 2 deletions esm/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const resolve = (template, values, xml, holed) => {
let entries = empty, markup = parser(template, prefix, xml);
if (holed) markup = markup.replace(
new RegExp(`<!--${prefix}\\d+-->`, 'g'),
'<!--{}-->$&<!--{/}-->'
'<!--{-->$&<!--}-->'
);
const content = createContent(markup, xml);
const { length } = template;
Expand All @@ -54,16 +54,25 @@ const resolve = (template, values, xml, holed) => {
let i = 0, search = `${prefix}${i++}`;
entries = [];
while (i < length) {
const node = tw.nextNode();
let node = tw.nextNode();
// these are holes or arrays
if (node.nodeType === COMMENT_NODE) {
if (node.data === search) {
// ⚠️ once array, always array!
const update = isArray(values[i - 1]) ? array : hole;
if (update === hole) replace.push(node);
else if (holed) {
// ⚠️ this operation works only with uhtml/dom
// it would bail out native TreeWalker
const { previousSibling, nextSibling } = node;
previousSibling.data = '[]';
nextSibling.remove();
}
entries.push(abc(createPath(node), update, null));
search = `${prefix}${i++}`;
}
// ⚠️ this operation works only with uhtml/dom
else if (holed && node.data === '#') node.remove();
}
else {
let path;
Expand Down
9 changes: 0 additions & 9 deletions esm/persistent-fragment.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,6 @@ const comment = value => document.createComment(value);

/** @extends {DocumentFragment} */
export class PersistentFragment extends custom(DocumentFragment) {
static adopt(content) {
const pf = new PersistentFragment(
document.createDocumentFragment()
);
pf.#firstChild = content.firstChild;
pf.#lastChild = content.lastChild;
pf.#nodes = [...content.childNodes];
return pf;
}
#firstChild = comment('<>');
#lastChild = comment('</>');
#nodes = empty;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"rollup:es": "rollup --config rollup/es.config.js",
"rollup:init": "rollup --config rollup/init.config.js",
"server": "npx static-handler .",
"size": "echo \"index $(cat index.js | brotli | wc -c)\";echo \"keyed $(cat keyed.js | brotli | wc -c)\";echo \"reactive $(cat reactive.js | brotli | wc -c)\";echo \"preactive $(cat preactive.js | brotli | wc -c)\";echo \"signal $(cat signal.js | brotli | wc -c)\";echo \"node $(cat node.js | brotli | wc -c)\";",
"size": "echo \"index $(cat index.js | brotli | wc -c)\";echo \"keyed $(cat keyed.js | brotli | wc -c)\";echo \"reactive $(cat reactive.js | brotli | wc -c)\";echo \"preactive $(cat preactive.js | brotli | wc -c)\";echo \"signal $(cat signal.js | brotli | wc -c)\";echo \"node $(cat node.js | brotli | wc -c)\";echo \"hydro $(cat hydro.js | brotli | wc -c)\";",
"test": "c8 node test/coverage.js && node test/modern.mjs",
"coverage": "mkdir -p ./coverage; c8 report --reporter=text-lcov > ./coverage/lcov.info",
"ts": "tsc -p ."
Expand Down
4 changes: 3 additions & 1 deletion rollup/ssr.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ const uhtml = readFileSync(init).toString();

const content = [
'const document = content ? new DOMParser().parseFromString(content, ...rest) : new Document;',
'const { constructor: DocumentFragment } = document.createDocumentFragment();'
'const { constructor: DocumentFragment } = document.createDocumentFragment();',
'document[__chunks__] = true;',
];

writeFileSync(init, `
// ⚠️ WARNING - THIS FILE IS AN ARTIFACT - DO NOT EDIT
import Document from './dom/document.js';
import DOMParser from './dom/dom-parser.js';
import { __chunks__ } from './dom/symbols.js';
/**
* @param {Document} document
Expand Down
12 changes: 8 additions & 4 deletions test/hydro.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import { render, html } from '../hydro.js';
function App(state) {
return html`
<h1>${state.title}</h1>
<div>
<ul>${[]}</ul>
<input autofocus>
<button onclick=${() => {
state.count++;
Expand Down Expand Up @@ -35,12 +37,14 @@
const Body = component(body, App);
render(body, Body(state));
</script>
</head><body><div>
</head><body><!--<>--><h1><!--{-->Hello Hydro<!--}--></h1>
<div>
<ul><!--[]--><!--[0]--></ul>
<input autofocus>
<button>
Clicked <!--{}-->0<!--{/}--> times
Clicked <!--{-->0<!--}--> times
</button>
</div></body>

</div><!--</>--></body>
<!--#-->
</html>

2 changes: 2 additions & 0 deletions test/hydro.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import init from '../esm/init-ssr.js';

function App(state) {
return html`
<h1>${state.title}</h1>
<div>
<ul>${[]}</ul>
<input autofocus>
<button onclick=${() => {
state.count++;
Expand Down
2 changes: 1 addition & 1 deletion test/parser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ const template = t => t;

console.log(
parser(template`a${1}b`, prefix, false)
.replace(re, '<!--{}-->$&<!--{/}-->')
.replace(re, '<!--{-->$&<!--}-->')
);
2 changes: 1 addition & 1 deletion test/ssr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const { document, render, html } = init(`

render(document.getElementById('test'), html`
<h1>
!!! ${'Hello SSR'} !!!
!!! ${[html`<a /><b />`, html`<c />`, html`<d />e`]} !!!
</h1>
`);

Expand Down
118 changes: 118 additions & 0 deletions test/virtual.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
const COMMENT_NODE = 8;

const mapped = new WeakMap;

const asArray = data => /^\[(\d+)\]$/.test(data) ? +RegExp.$1 : -1;

const faker = ({ tagName }) => ({ tagName, childNodes: [] });

const skipArray = (node, many) => {
return many;
};

// in a hole there could be:
// * a fragment
// * a hole
// * an element
// * a dom node
const skipHole = (fake, node) => {
let first = true, level = 0;
fake.unshift(node);
while ((node = node.previousSibling)) {
if (node.nodeType === COMMENT_NODE) {
const { data } = node;
if (data === '{') {
if (!level--) {
fake.unshift(node);
return;
}
}
else if (data === '}') {
if (!level++ && first) {
// hole in a hole
}
}
else if (first && data === '</>') {
// fragment in hole
}
}
// element or text in hole
else if (first) fake.unshift(node);
first = false;
}
};

const virtual = (parent, asFragment) => {
let ref = mapped.get(parent);
if (!ref) {
mapped.set(parent, (ref = faker(parent)));
const { childNodes: fake } = ref;
const { childNodes: live } = parent;
for (let { length } = live; length--;) {
let node = live[length];
switch (node.nodeType) {
case ELEMENT_NODE:
fake.unshift(virtual(node, false));
break;
case COMMENT_NODE: {
const { data } = node;
if (data === '}') {
skipHole(fake, node);
length -= 2;
}
else if (data === '</>') {

}
else {
fake.unshift(node);
const many = asArray(data);
if (-1 < many)
length -= skipArray(node, many);
}
break;
}
case TEXT_NODE:
if (asFragment && !node.data.trim()) break;
fake.unshift(node);
}
asFragment = false;
}
}
return ref;
};


import init from '../esm/init-ssr.js';

const { document, render, html } = init();

const reveal = ({ tagName, childNodes }, level = 0) => {
const out = [];
out.push('\n', ' '.repeat(level), `<${tagName}>`);
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
switch (node.nodeType) {
case COMMENT_NODE:
if (!i) out.push('\n', ' '.repeat(level + 1));
out.push(`<!--${node.data}-->`);
break;
case TEXT_NODE:
if (!i) out.push('\n', ' '.repeat(level + 1));
out.push(node.data);
break;
default:
out.push(reveal(node, level + 1));
break;
}
}
out.push('\n', ' '.repeat(level), `</${tagName}>`);
return out.join('');
};

render(document.body, html`<div>a${[html`b`]}c${[html`d`, html`e`]}f</div>`);
console.log(document.body.toString());

console.debug(virtual(document.body, false).childNodes[0]);
console.log(reveal(virtual(document.body, false)));

0 comments on commit ed8e7f9

Please sign in to comment.