v0.0.13: Refactored the render-loop to solve hard-to-track bugs and added more (elaborate) unit-tests to make sure it all works

This commit is contained in:
Miel Truyen 2020-04-15 16:33:32 +02:00
parent b95e5506d2
commit 0da07549e7
28 changed files with 1113 additions and 235 deletions

View File

@ -1 +1,3 @@
TODO the root build scripts are yet to be updated (building/watching and serving examples...) TODO:
- the root build scripts are yet to be updated (building/watching and serving examples...)
- Key-property is currently intepreted as per render function, it is preferrably per level in the hierarchy

View File

@ -27,6 +27,9 @@
<li> <li>
<a href="./table/">Tables (arrow functions)</a> <a href="./table/">Tables (arrow functions)</a>
</li> </li>
<li>
<a href="./table-2/">Tables 2 (Key-prop)</a>
</li>
</ul> </ul>
</body> </body>
</html> </html>

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Cerxes - CustomElements - SVG</title>
</head>
<body>
<script type="text/javascript" src="./index.js"></script>
</body>
</html>

132
examples/table-2/index.jsx Normal file
View File

@ -0,0 +1,132 @@
import {render, CustomElement, defineElement, Host, prop, state} from "../../packages/csx";
import style from "./index.scss";
import {TableComponent} from "./table-component";
@defineElement("table-tester")
class TableTester extends CustomElement{
/**
*
* @type {({[headerRender]: (function(): string), render: (function(User): *), [size]: number})[]}
*/
#columnDefinitions = [
{
headerRender: () => "Id",
render: (u) => u.userId,
size: 110
},
{
headerRender: () => "Email",
render: (u) => u.identity?.email,
},
{
headerRender: () => "FirstName",
render: (u) => u.identity?.firstName,
size: 160
},
{
headerRender: () => "LastName",
render: (u) => u.identity?.lastName,
size: 160
},
{
headerRender: () => "Up",
render: (u) => {
let d = new Date();
return (<button onClick={(ev) => this.moveUp(ev, u, d)}>Up</button>)
},
size: 110
},
{
headerRender: () => "Down",
render: (u) => {
let d = new Date();
return (<button onClick={(ev) => this.moveDown(ev, u, d)}>Down</button>)
},
size: 110
},
];
@state()
users = [];
rowKey = (user)=>user.userId;
cellRef = (user, colIdx, el)=>user.cells[colIdx]=el;
render(){
return <TableComponent
columns={this.#columnDefinitions}
data={this.users}
rowKey={this.rowKey}
cellRef={this.cellRef}
/>
}
connectedCallback() {
super.connectedCallback();
setTimeout(()=>this.load(), 0);
}
interval;
disconnectedCallback() {
super.disconnectedCallback();
if(this.interval) {
clearInterval(this.interval);
}
}
load(){
let users = [];
let rndFirstNames = ['Loes', 'Johnny', 'Maria', 'Jezus', 'Philippe', 'Filip', 'Annie'];
let rndLastNames = ['Peeters', 'Wachters', 'Jannsens', 'De Schaetzen', 'Becks', 'Konings', 'De Clerk'];
for(let i = 0; i < 10; ++i){
let first = rndFirstNames[Math.floor(rndFirstNames.length*Math.random())];
let last = rndLastNames[Math.floor(rndLastNames.length*Math.random())];
users.push({
userId: (Math.random()*99999).toString(36).slice(-6).toUpperCase(),
identity: {
firstName: first,
lastName: last,
email: `${first}.${last}@example.com`.toLocaleLowerCase()
},
cells: []
})
}
this.users = users;
}
lastClear = new Date();
moveUp = (ev, u, d)=>{
let data = this.users.slice();
let indexOf = this.users.indexOf(u);
if(indexOf>0) {
let [before, after] = data.splice(indexOf - 1, 2);
data.splice(indexOf - 1, 0, after, before);
console.log(data.map((u,index)=>this.rowKey(u,index)))
this.users = data;
}
console.log(this.users);
}
moveDown = (ev, u, d)=>{
let data = this.users.slice();
let indexOf = this.users.indexOf(u);
if(indexOf<(this.users.length-1)) {
let [before, after] = data.splice(indexOf, 2);
data.splice(indexOf, 0, after, before);
console.log(data.map((u,index)=>this.rowKey(u,index)));
this.users = data;
}
console.log(this.users);
}
}
document.body.appendChild(render(<style>{style}</style>));
document.body.appendChild(render(<TableTester/>));

View File

@ -0,0 +1,19 @@
html{
width: 100%;
height: 100%;
}
body{
display: flex;
flex-direction: column;
overflow: auto;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
.center-me{
align-self: center;
}

View File

@ -0,0 +1,72 @@
import {CustomElement, defineElement, Host, prop, state} from "../../packages/csx";
import TableComponentStyle from "./table-component.scss";
let tableId = 0;
@defineElement("tripto-table")
export class TableComponent extends CustomElement {
#columnDefinitions;
@prop()
set columns(value) {
this.#columnDefinitions = value;
}
@state() data;
@prop()
set data(value) {
this.data = value;
this.rows = new Map();
}
@prop()
rowKey = (value,index)=>index;
@prop()
cellRef = ()=>null;
rows = new Map();
#tableId = tableId++;
render() {
console.log(`Table render at for ${this.data?.length??0} rows: ${Date.now()}`);
return (
<Host>
<style>{TableComponentStyle}</style>
<style>
{this.#columnDefinitions?.map((col, idx) => (
`#table_${this.#tableId} .cell.cell_${idx} {` +
` flex: ${(col.size ? (`0 0 ${col.size}px`) : `1`)};` +
`}`
))}
</style>
<section className="table" id={`table_${this.#tableId}`}>
<header>
<div className="row">
{this.#columnDefinitions.map((col, idx) => (
<div className={`cell cell_${idx}`}>
{col.headerRender()}
</div>
))}
</div>
</header>
<main>
{this.data?.map((dataRow,index) => (
<div className="row"
key={this.rowKey(dataRow, index)}
ref={(el)=>this.rows.set(this.rowKey(dataRow,index), el)}>
{this.#columnDefinitions.map((col, idx) => (
<div className={`cell cell_${idx}`} key={idx} ref={(el)=>this.cellRef(dataRow,idx,el)}>
{col.render(dataRow)}
</div>
))}
</div>
))}
</main>
</section>
</Host>
);
}
}

View File

@ -0,0 +1,32 @@
.table {
--box-color: #a0a0a0;
--primary-color: #5f74ff;
--table-background: #e4e4f0;
--box-border: 1px solid #7d7d7d;
border: var(--box-border);
display: block;
header {
color: var(--primary-color);
}
header > .row,
main > .row {
background: var(--table-background);
display: flex;
width: 100%;
line-height: 3em;
border-bottom: var(--box-border);
.cell {
padding: 0 15px;
}
}
main > .row {
&:last-child {
border-bottom: 0;
}
}
}

View File

@ -0,0 +1,169 @@
import { render, CustomElement, Host, ShadowDOM, defineElement, state, prop } from "@cerxes/csx";
import { testContainer } from "../utils/test-container";
import { nextAnimationFrame } from "../utils/next-animation-frame";
describe("Shadow-DOM tests", () => {
/**
* Assert that shadow dom behaves as expected
*/
test("Simple shadow-component", async () => {
@defineElement('shadow-component')
class ShadowComponent extends CustomElement{
@prop()
title = 'Content here';
render(){
return (
<Host>
<ShadowDOM>
<div>
<h1>{this.title}</h1>
<slot />
</div>
</ShadowDOM>
</Host>
)
}
}
let initialVSpec = <ShadowComponent />;
let rendered = render(initialVSpec);
let container = testContainer(rendered);
document.body.appendChild(container);// Components need to be added to the DOM or their connectecCallback will not be called
// Initial render
expect(
container.innerHTML
).toBe([
`<shadow-component>`,
`</shadow-component>`,
].join(''));
expect(
rendered.shadowRoot.innerHTML
).toBe([
`<div>`,
`<h1>Content here</h1>`,
`<slot></slot>`,
`</div>`,
].join(''));
// Update behaves as it should
let updatedVSpec = (
<ShadowComponent title={"New content here"}>
<li><ul>contents</ul></li>
</ShadowComponent>
);
render(updatedVSpec, {host: rendered, old: initialVSpec});
// Wait for it to update
await nextAnimationFrame();
expect(
container.innerHTML
).toBe([
`<shadow-component>`,
`<li><ul>contents</ul></li>`,
`</shadow-component>`,
].join(''));
expect(
rendered.shadowRoot.innerHTML
).toBe([
`<div>`,
`<h1>New content here</h1>`,
`<slot></slot>`,
`</div>`,
].join(''));
document.body.removeChild(container);
});
test("Nested shadow-component", async () => {
@defineElement('todo-item')
class TodoItem extends CustomElement {
@prop()
get model(){ return this.#model; }
set model(value){ this.#model = value; }
#model;
render(){
return (
<Host>
<ShadowDOM>
<input type="checkbox" checked={ this.model.checked }/>
<label>
<slot />
</label>
</ShadowDOM>
</Host>
)
}
}
@defineElement('my-todo')
class MyTodo extends CustomElement {
@state()
todos = [
{ text: "todo 1", checked: true },
{ text: "todo 2", checked: false },
];
rendered = [];
render(){
return (
<Host>
<h1>Todos</h1>
<ul>
{this.todos.map((todo,index)=>(
<TodoItem model={todo}
ref={(el)=>this.rendered[index]=el}
>
{todo.text}
</TodoItem>
))}
</ul>
</Host>
)
}
}
let initialVSpec = <MyTodo />;
let rendered = render(initialVSpec);
let container = testContainer(rendered);
document.body.appendChild(container);// Components need to be added to the DOM or their connectecCallback will not be called
// Initial render
expect(
container.innerHTML
).toBe([
`<my-todo>`,
`<h1>Todos</h1>`,
`<ul>`,
...rendered.todos.map(todo=>(
`<todo-item>${todo.text}</todo-item>`
)),
`</ul>`,
`</my-todo>`,
].join(''));
for(let i = 0; i < rendered.todos.length; ++i){
let todo = rendered.todos[i];
let el = rendered.rendered[i];
expect(el).not.toBeUndefined();
expect(
el.shadowRoot.innerHTML
).toBe([
`<input type="checkbox"${todo.checked? ' checked=""': ''}>`,
`<label>`,
`<slot></slot>`,
`</label>`
].join(''));
}
document.body.removeChild(container);
});
});

159
jest/components/svg.test.js Normal file
View File

@ -0,0 +1,159 @@
import { render, CustomElement, Host, ShadowDOM, defineElement, state, prop } from "@cerxes/csx";
import { testContainer } from "../utils/test-container";
import { nextAnimationFrame } from "../utils/next-animation-frame";
const svgs = {
"/assets/icons/checkbox-checked.svg": [
`<svg fill="currentcolor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">`,
`<title>checkbox-checked</title>`,
`<g><path d="M401.7,411.8c-38,0-76.1,0-114.1,0c-55.6,0-111.2,0-166.8,0c-6.3,0-12.6,0-19,0c-1.1,0-2.3,0-3.4,0 c-0.5,0-1.1,0-1.5,0c-4.5,0.1,4.2,1.3,0,0c-0.9-0.3-2.2-0.8-3.2-0.9c-2.3-0.2,4,2.4,0.6,0.2c-0.2-0.1-3.4-1.9-3.3-2 c0.1-0.2,3.5,3.2,0.1-0.2c-0.6-0.6-1.2-1.2-1.7-1.9c1.8,2.4,2.1,2.7,0.8,0.9c-0.1-0.2-2-3.3-1.9-3.4c0.7-0.4,1.2,4.6,0.4,0.5 c-0.1-0.3-0.9-3.2-0.8-3.2c0.1,0.9,0.2,1.8,0.4,2.6c-0.1-0.9-0.1-1.8-0.1-2.7c0-0.2,0-0.5,0-0.7c0-1.5,0-3.1,0-4.6 c0-7.2,0-14.5,0-21.7c0-56.3,0-112.6,0-168.9c0-25.9,0-51.9,0-77.8c0-7.6,0-15.3,0-22.9c0-1.8,0-3.6,0-5.4c0-0.4,0-0.7,0-1.1 c0-0.5,0-1.1,0-1.5c0-3.3,0.5,1.4-0.3,1.3c0,0,1.1-5.2,1.4-5.1c0.1,0-2.2,4.2,0.2,0.1c2.4-4.1,0,0-0.2-0.2 c-0.1-0.1,2.5-2.6,2.7-2.8c2.9-3.1-3.7,2,0,0c0.7-0.4,1.5-0.9,2.2-1.3c3.6-2.3-4.2,1,0,0c0.3-0.1,3.8-1.1,3.9-1 c-3.1,0.4-3.5,0.4-1.3,0.3c0.5,0,0.9,0,1.4,0c0.9,0,1.8,0,2.6,0c5.9,0,11.8,0,17.7,0c24,0,48,0,72,0c58.1,0,116.3,0,174.4,0 c12.2,0,24.4-0.2,36.6,0c0.5,0,1.2,0,1.4,0c4.5-0.1-4.2-1.3,0,0c0.3,0.1,3.8,0.9,3.8,1.1c0,0.1-4.2-2.2-0.1,0.2 c4.1,2.4,0,0,0.2-0.2c0.1-0.1,2.1,2,2.3,2.2c3.5,3.1-1.2-2.7,0.5,0.5c0.1,0.2,2,3.3,1.9,3.4c-0.3,0.1-1.5-4.5-0.2,0.1 c0.1,0.4,0.2,0.9,0.3,1.3c0.4,2.2,0.4,1.7-0.1-1.3c0.1,0,0.1,2.7,0.1,2.7c0,0.2,0,0.5,0,0.7c0,4.5,0,9.1,0,13.7 c0,53.1,0,106.2,0,159.2c0,42.9,0.2,85.8,0,128.7c0,0.3,0,0.7,0,1c0,0.5,0,1.1,0,1.5c0.1,4.5,1.3-4.2,0,0 c-0.1,0.3-0.9,3.8-1.1,3.8c-0.1,0,2.2-4.2-0.2-0.1c-0.2,0.3-1.3,2.2-1.4,2.2c-0.2-0.1,3.2-3.5-0.2-0.1c-3.3,3.3,0,0,0.1,0.2 c0.1,0.2-3.1,1.9-3.3,2c-3.6,2.3,4.2-1,0,0c-0.3,0.1-3.8,1.1-3.9,1c0.9-0.1,1.8-0.2,2.6-0.4C403.5,411.7,402.6,411.8,401.7,411.8 c-10.5,0.2-20.5,9-20,20c0.5,10.6,8.8,20.3,20,20c27.8-0.6,49.6-22.6,50.1-50.3c0.1-4.1,0-8.3,0-12.4c0-22.3,0-44.6,0-67 c0-60,0-120.1,0-180.1c0-14.4,0.2-28.8,0-43.2c-0.3-23.1-16.1-44.8-39.4-49.4c-6.3-1.3-12.3-1.2-18.7-1.2 c-51.3,0-102.5,0-153.8,0c-46.5,0-93,0-139.5,0c-17.6,0-33,7.3-43.3,21.6c-7,9.7-9,21.1-9,32.8c0,18.3,0,36.6,0,54.8 c0,60.9,0,121.8,0,182.8c0,18.8,0,37.6,0,56.5c0,10.2,1.3,20,6.2,29.2c8.9,16.8,27,25.9,45.6,25.9c45.4,0.1,90.7,0,136.1,0 c52.2,0,104.4,0,156.7,0c3,0,6,0,9,0c10.5,0,20.5-9.2,20-20C421.2,420.9,412.9,411.8,401.7,411.8z"></path></g>`,
`<g><path d="M136.9,276.6c20,18,40,36,60.1,54.1c8.1,7.3,20.2,7.8,28.3,0c15.6-15,31.1-30,46.7-45.1 c24.7-23.8,49.4-47.7,74.1-71.5c5.7-5.5,11.4-11,17.2-16.6c7.5-7.3,7.9-21.1,0-28.3c-8.1-7.4-20.2-7.8-28.3,0 c-15.6,15-31.1,30-46.7,45.1c-24.7,23.8-49.4,47.7-74.1,71.5c-5.7,5.5-11.4,11-17.2,16.6c9.4,0,18.9,0,28.3,0 c-20-18-40-36-60.1-54.1c-7.8-7-20.7-8.2-28.3,0C129.9,256,128.6,269.2,136.9,276.6L136.9,276.6z"></path></g>`,
`</svg>`
].join(''),
"/assets/icons/checkbox.svg": [
`<svg fill="currentcolor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">`,
`<title>checkbox</title>`,
`<g><path d="M401.7,411.8c-38,0-76.1,0-114.1,0c-55.6,0-111.2,0-166.8,0c-6.3,0-12.6,0-19,0c-1.1,0-2.3,0-3.4,0 c-0.5,0-1.1,0-1.5,0c-4.5,0.1,4.2,1.3,0,0c-0.9-0.3-2.2-0.8-3.2-0.9c-2.3-0.2,4,2.4,0.6,0.2c-0.2-0.1-3.4-1.9-3.3-2 c0.1-0.2,3.5,3.2,0.1-0.2c-0.6-0.6-1.2-1.2-1.7-1.9c1.8,2.4,2.1,2.7,0.8,0.9c-0.1-0.2-2-3.3-1.9-3.4c0.7-0.4,1.2,4.6,0.4,0.5 c-0.1-0.3-0.9-3.2-0.8-3.2c0.1,0.9,0.2,1.8,0.4,2.6c-0.1-0.9-0.1-1.8-0.1-2.7c0-0.2,0-0.5,0-0.7c0-1.5,0-3.1,0-4.6 c0-7.2,0-14.5,0-21.7c0-56.3,0-112.6,0-168.9c0-25.9,0-51.9,0-77.8c0-7.6,0-15.3,0-22.9c0-1.8,0-3.6,0-5.4c0-0.4,0-0.7,0-1.1 c0-0.5,0-1.1,0-1.5c0-3.3,0.5,1.4-0.3,1.3c0,0,1.1-5.2,1.4-5.1c0.1,0-2.2,4.2,0.2,0.1c2.4-4.1,0,0-0.2-0.2 c-0.1-0.1,2.5-2.6,2.7-2.8c2.9-3.1-3.7,2,0,0c0.7-0.4,1.5-0.9,2.2-1.3c3.6-2.3-4.2,1,0,0c0.3-0.1,3.8-1.1,3.9-1 c-3.1,0.4-3.5,0.4-1.3,0.3c0.5,0,0.9,0,1.4,0c0.9,0,1.8,0,2.6,0c5.9,0,11.8,0,17.7,0c24,0,48,0,72,0c58.1,0,116.3,0,174.4,0 c12.2,0,24.4-0.2,36.6,0c0.5,0,1.2,0,1.4,0c4.5-0.1-4.2-1.3,0,0c0.3,0.1,3.8,0.9,3.8,1.1c0,0.1-4.2-2.2-0.1,0.2 c4.1,2.4,0,0,0.2-0.2c0.1-0.1,2.1,2,2.3,2.2c3.5,3.1-1.2-2.7,0.5,0.5c0.1,0.2,2,3.3,1.9,3.4c-0.3,0.1-1.5-4.5-0.2,0.1 c0.1,0.4,0.2,0.9,0.3,1.3c0.4,2.2,0.4,1.7-0.1-1.3c0.1,0,0.1,2.7,0.1,2.7c0,0.2,0,0.5,0,0.7c0,4.5,0,9.1,0,13.7 c0,53.1,0,106.2,0,159.2c0,42.9,0.2,85.8,0,128.7c0,0.3,0,0.7,0,1c0,0.5,0,1.1,0,1.5c0.1,4.5,1.3-4.2,0,0 c-0.1,0.3-0.9,3.8-1.1,3.8c-0.1,0,2.2-4.2-0.2-0.1c-0.2,0.3-1.3,2.2-1.4,2.2c-0.2-0.1,3.2-3.5-0.2-0.1c-3.3,3.3,0,0,0.1,0.2 c0.1,0.2-3.1,1.9-3.3,2c-3.6,2.3,4.2-1,0,0c-0.3,0.1-3.8,1.1-3.9,1c0.9-0.1,1.8-0.2,2.6-0.4C403.5,411.7,402.6,411.8,401.7,411.8 c-10.5,0.2-20.5,9-20,20c0.5,10.6,8.8,20.3,20,20c27.8-0.6,49.6-22.6,50.1-50.3c0.1-4.1,0-8.3,0-12.4c0-22.3,0-44.6,0-67 c0-60,0-120.1,0-180.1c0-14.4,0.2-28.8,0-43.2c-0.3-23.1-16.1-44.8-39.4-49.4c-6.3-1.3-12.3-1.2-18.7-1.2c-51.3,0-102.5,0-153.8,0 c-46.5,0-93,0-139.5,0c-17.6,0-33,7.3-43.3,21.6c-7,9.7-9,21.1-9,32.8c0,18.3,0,36.6,0,54.8c0,60.9,0,121.8,0,182.8 c0,18.8,0,37.6,0,56.5c0,10.2,1.3,20,6.2,29.2c8.9,16.8,27,25.9,45.6,25.9c45.4,0.1,90.7,0,136.1,0c52.2,0,104.4,0,156.7,0 c3,0,6,0,9,0c10.5,0,20.5-9.2,20-20C421.2,420.9,412.9,411.8,401.7,411.8z"></path></g>`,
`</svg>`
].join('')
};
const iconStyle = `:host { display: inline-block; }`;// The rest is left out
describe("SVG Component tests", () => {
/**
* Assert that a basic component renders as expected
*/
test("Simple example-component", async () => {
let svgCache = new Map();
async function fetchSvg(svgUrl){
// Fake timer
await new Promise((resolve,reject)=>setTimeout(()=>resolve(), 1));
let svg = svgs[svgUrl];
if(svg){
return {
text: ()=>svg
}
}else{
throw new Error("Not found");
}
}
/**
* @param {string} svgUrl
* @returns {Promise<Element>}
*/
async function loadSvg(svgUrl) {
const response = await fetchSvg(svgUrl);
const svgResource = await response.text();
const parser = new DOMParser();
const svgDocument = parser.parseFromString(svgResource, "image/svg+xml");
let svgElement = svgDocument.firstElementChild;
if (svgElement.hasAttribute("fill")) {
svgElement.setAttribute("fill", "currentcolor")
}
svgCache.set(svgUrl, svgElement);
return svgElement;
}
@defineElement("test-icon")
class Icon extends CustomElement {
// Again JEST fucks this up in that these magically become read-only (probably not using our CSX-version of babel!)
@state()
set svgElement(value){ this.#svgElement = value};
get svgElement(){ return this.#svgElement};
#svgElement;
/**
* @param {string} icon
*/
@prop() set icon(icon) {
if(icon !== this.#icon) {
this.#icon = icon;
this.updateIcon();
}
};
get icon(){
return this.#icon;
}
#icon;
updateIcon(){
let icon = this.#icon;
const svgUrl = `/assets/icons/${icon}.svg`;
let cached = svgCache.get(svgUrl);
if(cached){
// Use from cache (without passing by async functions, to optimize rendering loop!)
this.svgElement = cached.cloneNode(true);
}else{
loadSvg(svgUrl).then(svgEl=> {
if(icon===this.#icon) {
// If this is still the desired icon, load it
this.svgElement = svgEl.cloneNode(true);
}
});
}
}
render() {
return (
<Host>
<ShadowDOM>
<style>{iconStyle}</style>
<div className="icon">
{this.svgElement}
</div>
</ShadowDOM>
</Host>
);
}
}
let initialVSpec = <Icon icon={'checkbox'}/>
let rendered = render(initialVSpec);
let container = testContainer(rendered);
document.body.appendChild(container);// Components need to be added to the DOM or their connectecCallback will not be called
expect(
container.innerHTML
).toBe([
`<test-icon>`,
`</test-icon>`,
].join(''));
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),10));
await nextAnimationFrame();
expect(
rendered.shadowRoot.innerHTML
).toBe([
`<style>${iconStyle}</style>`,
`<div class="icon">`,
svgs["/assets/icons/checkbox.svg"],
`</div>`
].join(''));
let updatedVSpec = <Icon icon={'checkbox-checked'}/>;
render(updatedVSpec, {host: rendered, old: initialVSpec});
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),10));
await nextAnimationFrame();
expect(
rendered.shadowRoot.innerHTML
).toBe([
`<style>${iconStyle}</style>`,
`<div class="icon">`,
svgs["/assets/icons/checkbox-checked.svg"],
`</div>`
].join(''));
document.body.removeChild(container);
});
});

View File

@ -194,13 +194,14 @@ describe("Children", () => {
].join('')); ].join(''));
}); });
test("Nulls are ignored", async () => { test("Nulls and undefined are ignored", async () => {
expect( expect(
testContainer( testContainer(
render( render(
<div class="container"> <div class="container">
<h1>Title</h1> <h1>Title</h1>
{null} {null}
{undefined}
<button>Save</button> <button>Save</button>
</div> </div>
) )
@ -212,4 +213,50 @@ describe("Children", () => {
`</div>` `</div>`
].join('')); ].join(''));
}); });
test("Update maintains the same elements where possible", async () => {
let initialVSpec = (
<div class="container">
<h1>Title</h1>
<button>Save</button>
</div>
);
let rendered = render(initialVSpec);
let container = testContainer(rendered);
let children = Array.from(rendered.childNodes);// Capture current child-nodes
expect(
container.innerHTML
).toBe([
`<div class="container">`,
`<h1>Title</h1>`,
`<button>Save</button>`,
`</div>`
].join(''));
// Update it
let updatedVSpec = (
<div class="container">
<h1>Update</h1>
<button>Dismiss</button>
</div>
);
render(updatedVSpec, {host: rendered, old: initialVSpec});
expect(
container.innerHTML
).toBe([
`<div class="container">`,
`<h1>Update</h1>`,
`<button>Dismiss</button>`,
`</div>`
].join(''));
let updatedChildren = Array.from(rendered.childNodes);// Capture current child-nodes
expect(children.length).toBe(updatedChildren.length);
for(let i = 0; i < children.length; ++i){
expect(children[i] === updatedChildren[i]).toBe(true);// Expect the element to be the same by ref
}
});
}); });

View File

@ -3,14 +3,14 @@ import { testContainer } from "../utils/test-container";
describe("Key-property tests", () => { describe("Key-property tests", () => {
test("Keyed list", async () => { test("Keyed list", async () => {
let renderedIndexes = []; let initialRendered = new Map();
let initIndexes = [0, 1, 2, 3]; let initIndexes = [1, 2, 3, 4];
let makeSpec = (targetList, indexes)=>( let makeSpec = (targetList, indexes)=>(
<ul> <ul>
{ {
indexes.map(index => ( indexes.map(index => (
<li id={`li_${index}`} key={index} ref={(el) => targetList[ index ] = el}> <li id={`li_${index}`} key={index} ref={(el) => targetList.set(index, el)}>
{index} {index}
</li> </li>
)) ))
@ -18,7 +18,7 @@ describe("Key-property tests", () => {
</ul> </ul>
); );
let initialVSpec = makeSpec(renderedIndexes, initIndexes); let initialVSpec = makeSpec(initialRendered, initIndexes);
let rendered = render(initialVSpec); let rendered = render(initialVSpec);
let container = testContainer(rendered); let container = testContainer(rendered);
@ -31,17 +31,18 @@ describe("Key-property tests", () => {
].join('') ].join('')
); );
expect(renderedIndexes.length).toBe(4); expect(initialRendered.size).toBe(4);
for(let rendered of renderedIndexes){ for(let rendered of initialRendered){
expect(rendered).not.toBeUndefined(); expect(rendered).not.toBeUndefined();
} }
let reorderedIndexes = [3,2,1,0]; // Reverse order
let rerenderedIndexes = renderedIndexes.slice(); let reorderedIndexes = [4,3,2,1];
let rerenderedIndexes = new Map(initialRendered);
let updatedVSpec = makeSpec(rerenderedIndexes, reorderedIndexes); let updatedVSpec = makeSpec(rerenderedIndexes, reorderedIndexes);
render(updatedVSpec, {host: rendered, old: initialVSpec});// Is this host right? it seems inconsistent and the source of our bug (as it is probably also misused in custom-elements) render(updatedVSpec, {host: rendered, old: initialVSpec});
// Updated // Updated (reverse order)
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
[ [
`<ul>`, `<ul>`,
@ -51,11 +52,46 @@ describe("Key-property tests", () => {
); );
// Validate that items were merely re-arranged and not re-created // Validate that items were merely re-arranged and not re-created
expect(rerenderedIndexes.length).toBe(4); expect(rerenderedIndexes.size).toBe(4);
for(let i=0; i<4; ++i){ for(let i of initIndexes){
let initRendered = renderedIndexes[i]; let initRendered = initialRendered.get(i);
let reorderedRendered = rerenderedIndexes[i]; let reorderedRendered = rerenderedIndexes.get(i);
expect(initRendered === reorderedRendered).toBe(true); // These should've remained the same expect(initRendered === reorderedRendered).toBe(true); // These should've remained the same
} }
// Add items and change order
let additionalIndexes = [0, 1, 2.5, 2, 3, 4, 5.5];
let additionalRerenderedIndexes = new Map(initialRendered);
let secondUpdatedVSpec = makeSpec(additionalRerenderedIndexes, additionalIndexes);
render(secondUpdatedVSpec, {host: rendered, old: updatedVSpec});// Is this host right? it seems inconsistent and the source of our bug (as it is probably also misused in custom-elements)
// Validate add items and changed order
expect(container.innerHTML).toBe(
[
`<ul>`,
...additionalIndexes.map(index=>`<li id="${`li_${index}`}">${index}</li>`),
`</ul>`
].join('')
);
// Validate that items were merely re-arranged and not re-created
expect(additionalRerenderedIndexes.size).toBe(additionalIndexes.length);
for(let i of initIndexes){
let initRendered = initialRendered.get(i);
let additionalRendered = additionalRerenderedIndexes.get(i);
expect(initRendered === additionalRendered).toBe(true); // These should've still remained the same
}
// Revert back to the original
render(initialVSpec, {host: rendered, old: secondUpdatedVSpec});// Is this host right? it seems inconsistent and the source of our bug (as it is probably also misused in custom-elements)
// Validate reverting back to the original
expect(container.innerHTML).toBe(
[
`<ul>`,
...initIndexes.map(index=>`<li id="${`li_${index}`}">${index}</li>`),
`</ul>`
].join('')
);
}); });
}); });

View File

@ -0,0 +1,62 @@
import { render } from "@cerxes/csx";
import { testContainer } from "../utils/test-container";
describe("SVG-rendering test", () => {
test("Simple", async () => {
let makeSpec = (stroke, strokeWidth) => (
<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg"
stroke={stroke}>
<g fill="none" fill-rule="evenodd">
<g transform="translate(1 1)" stroke-width={strokeWidth}>
<circle stroke-opacity=".5" cx="18" cy="18" r="18"/>
<path d="M36 18c0-9.94-8.06-18-18-18">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1s"
repeatCount="indefinite"/>
</path>
</g>
</g>
</svg>
)
let initialVSpec = makeSpec("#000", 2);
let rendered = render(initialVSpec);
let container = testContainer(rendered);
expect(container.innerHTML).toBe([
`<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#000">`,
`<g fill="none" fill-rule="evenodd">`,
`<g transform="translate(1 1)" stroke-width="2">`,
`<circle stroke-opacity=".5" cx="18" cy="18" r="18">`,
`</circle>`,
`<path d="M36 18c0-9.94-8.06-18-18-18">`,
`<animateTransform attributeName="transform" type="rotate" from="0 18 18" to="360 18 18" dur="1s" repeatCount="indefinite">`,
`</animateTransform>`,
`</path>`,
`</g>`,
`</g>`,
`</svg>`
].join(''));
let updatedVSpec = makeSpec("#FFF", 4);
render(updatedVSpec, { host: rendered, old: initialVSpec });
expect(container.innerHTML).toBe([
`<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#FFF">`,
`<g fill="none" fill-rule="evenodd">`,
`<g transform="translate(1 1)" stroke-width="4">`,
`<circle stroke-opacity=".5" cx="18" cy="18" r="18">`,
`</circle>`,
`<path d="M36 18c0-9.94-8.06-18-18-18">`,
`<animateTransform attributeName="transform" type="rotate" from="0 18 18" to="360 18 18" dur="1s" repeatCount="indefinite">`,
`</animateTransform>`,
`</path>`,
`</g>`,
`</g>`,
`</svg>`
].join(''));
});
});

View File

@ -1,5 +1,4 @@
# Don't publish the src containing ESNext proposal's code. Only publish the bundled output in dist/ and the ES6-transpiled src from lib/ # Don't publish the src containing ESNext proposal's code. Only publish the bundled output in dist/ and the ES6-transpiled src from lib/
src/*
node_modules/* node_modules/*
rollup.config.js rollup.config.js
yarn.lock yarn.lock

View File

@ -110,7 +110,7 @@ export class CsxConfig {
let srcOpts = { let srcOpts = {
es: format==='es', es: format==='es',
single: type==='lib', single: type==='lib',
sourcemap: true,// Just always there for now sourcemap: minified ? true : (format==='es'? false : true),
minified: !!minified minified: !!minified
}; };
let outDir = type==='lib'? lib : dist; let outDir = type==='lib'? lib : dist;

View File

@ -1,6 +1,6 @@
{ {
"name": "@cerxes/csx", "name": "@cerxes/csx",
"version": "0.0.11", "version": "0.0.13",
"author": "Miel Truyen <miel.truyen@cerxes.net>", "author": "Miel Truyen <miel.truyen@cerxes.net>",
"description": "CSX is a minimalistic UI-framework inspired by React+JSX for usage with WebComponents.", "description": "CSX is a minimalistic UI-framework inspired by React+JSX for usage with WebComponents.",
"repository": { "repository": {

View File

@ -0,0 +1,58 @@
import './types';
import {
HostNodeRenderer, Host,
ShadowNodeRenderer, ShadowDOM,
PrimitiveRenderer, Primitive,
NodeTreeRenderer, NativeRenderer
} from "./renderers";
/**
* Meta data used to handle a node in the render lope
* @typedef {VNodeRendererMeta} VNodeMeta
* @category VDOM.renderer
* @property {VNodeRenderer} renderer - The renderer to use to update, create or delete this node
* @property {VNodeType} normedType - Normed type of the node
**/
/**
*
* @param vnode
* @returns {VNodeMeta|undefined}
*/
export function getNodeMeta(vnode) {
if (vnode === undefined || vnode === null) return undefined; // Indicate it shouldn't render
if (vnode instanceof Node){
return {
...NativeRenderer.meta(),
renderer: NativeRenderer,
normedType: Node
};
}
let type = vnode?.type;
if (!type){
return {
...PrimitiveRenderer.meta(),
renderer: PrimitiveRenderer,
normedType: Primitive
};
} else if (type === Host){
return {
...HostNodeRenderer.meta(),
renderer: HostNodeRenderer,
normedType: Host
};
} else if (type === ShadowDOM){
return {
...ShadowNodeRenderer.meta(),
renderer: ShadowNodeRenderer,
normedType: ShadowDOM
};
} else {
return {
...NodeTreeRenderer.meta(),
renderer: NodeTreeRenderer,
normedType: window.customElements?.get(type) ?? type
};
}
}

View File

@ -1,71 +1,53 @@
import './types'; import './types';
import { import { getNodeMeta } from "./node-meta";
HostNodeRenderer, Host,
ShadowNodeRenderer, ShadowDOM,
PrimitiveRenderer, Primitive,
NodeTreeRenderer, NativeRenderer
} from "./renderers";
export function getNodeMeta(vnode) {
if (vnode === undefined || vnode === null) return undefined; // Indicate it shouldn't render
if (vnode instanceof Node) return { renderer: NativeRenderer, normedType: Node };
let type = vnode?.type;
if (!type) return { renderer: PrimitiveRenderer, normedType: Primitive };
else if (type === Host) return { renderer: HostNodeRenderer, normedType: Host };
else if (type === ShadowDOM) return { renderer: ShadowNodeRenderer, normedType: ShadowDOM };
else return { renderer: NodeTreeRenderer, normedType: window.customElements?.get(type) ?? type };
}
/**
* @typedef {Object} RenderOptions
* @category VDOM
* @property {Element} [host] - The element to update to the specified VDOM
* @property {VNode} [old] - Old VNode representation of rendered host
* @property {Document} [document] - The document we're rendering to
* @property {Element} [parent] - The parent element (TODO not sure what this will do when specified; Insert it as child element of the parent where?)
*/
/** /**
* This exists as a very basic example/test for JSX-to-DOM * This exists as a very basic example/test for JSX-to-DOM
* @category VDOM * @category VDOM
* @param {VNode} vnode * @param {VNode} vnode
* @param {RenderOptions} opts * @param {RenderOptions} [opts]
* @param {Element} [opts.host] - The element to update to the specified VDOM
* @param {VNode} [opts.old] - Old VNode representation of rendered host
* @param {Document} [opts.document] - The document we're rendering to
* @return {Element} * @return {Element}
*/ */
export function render(vnode, opts = {}) { export function render(vnode, opts = {}) {
// TODO this code could use restructuring when opts.host and vnode.type are incompatible (non updatable type), the host element should be replaced
// with a newly created element, like it does with all child-elements..
// General flow of this code is to process the hierarchy using a queue (so no recursion is used) // General flow of this code is to process the hierarchy using a queue (so no recursion is used)
// on each node of the hierarchy a renderer is determined which is compared to the renderer of the previous version of this vnode-hierarchy // on each node of the hierarchy a renderer is determined which is compared to the renderer of the previous version of this vnode-hierarchy
// to determine if these nodes can be updated (e.g updating a div, or textnode) and if they behave as a child-node (e.g. shows up in childNodes) // to determine if these nodes can be updated (e.g updating a div, or textnode) and if they behave as a child-node (e.g. shows up in childNodes)
// or are some other special type of node (like Host or ShadowDOM) // or are some other special type of node (like Host or ShadowDOM)
/** /** @type {VRenderState} */
*
* @type {VRenderState}
*/
let state = { let state = {
keyedElements: new Map(),
refs: [], refs: [],
queue: [{ queue: [{
// Start item // Start item
item: { item: {
document: opts.document || document, document: opts.document || document,
host: opts.host, host: opts.host,
parent: opts.parent,
old: opts.old, old: opts.old,
vnode: vnode vnode: vnode
}, },
meta: getNodeMeta(vnode) meta: getNodeMeta(vnode),
parent: null,
attached: true// Root item is treated as if it is attached
}] }]
}; };
let newRoot = undefined; let newRoot = undefined;
while (state.queue.length > 0) { while (state.queue.length > 0) {
let { item, meta, previous } = state.queue.splice(0, 1)[ 0 ]; /** @type {VRenderQueueItem} */
let queueItem = state.queue.shift();
let {
/** @type {VRenderItem} */ item,
/** @type {VNodeMeta} */ meta,
/** @type {VRenderItem} */ previousNode,
/** @type {boolean} */ attached
} = queueItem;
/** @type {VNodeRenderer} */
let renderer = meta.renderer; let renderer = meta.renderer;
if (!renderer) throw new Error("No renderer for vnode", item.vnode); if (!renderer) throw new Error("No renderer for vnode", item.vnode);
// SVG handling.. // SVG handling.. (this could be improved)
if (!item.inSvg && item.vnode?.type === 'svg') item.inSvg = true; if (!item.inSvg && item.vnode?.type === 'svg') item.inSvg = true;
else if (item.inSvg && item.vnode?.type === 'foreignObject') item.inSvg = false; else if (item.inSvg && item.vnode?.type === 'foreignObject') item.inSvg = false;
@ -75,7 +57,8 @@ export function render(vnode, opts = {}) {
item.host = renderer.create(item, meta); item.host = renderer.create(item, meta);
newlyCreated = true; newlyCreated = true;
if (item.vnode?.props?.ref) {// If props specify a ref-function, queue it to be called at the end of the render // If props specify a ref-function, queue it to be called at the end of the render
if (item.vnode?.props?.ref) {
state.refs.push([item.vnode.props.ref, item.host]); state.refs.push([item.vnode.props.ref, item.host]);
} }
} }
@ -84,180 +67,166 @@ export function render(vnode, opts = {}) {
renderer.update(item, meta); renderer.update(item, meta);
// Update children // Update children
if (meta.normedType !== Node && (item.vnode?.children || item.old?.children)) { if (meta.hasChildren) {
let childTypes = new Set(); /** @type {FlattenedVNode[]} */
let vChildren = flattenVNodeChildren(item.vnode);
/** @type {FlattenedOldVNode[]} */
let oldVChildren = flattenVNodeChildren(item?.old);
/** @type {Map<*, FlattenedOldVNode>} */
let keyedChildren = new Map();// Old child-items
/** @type {NodeState[]} */
let nodes = Array.from(item.host.childNodes).map(node=>({node, attached: true}));
/** @type {FlattenedOldVNode[]} */
let specialOldVChildren = []; // List of old-vnodes that are not node-type node and have no key
// Flatten and organize new vNode-children (this could be a separate function, or implemented using a helper function (because mucht of the code is similar between old/new vnodes) // Iterate old children and link them to their corresponding elements
/** let nodeI = 0, nodesLength = nodes.length;
* @type { Object.<VNodeType, Array.<VRenderQueueItem>> } let oldVItemI = 0, oldVItemLength = oldVChildren.length;
*/ while(oldVItemI < oldVItemLength){
let vChildren = {}; /** @type {FlattenedOldVNode} */
let queue = (item.vnode?.children || []).slice(); let oldVItem = oldVChildren[oldVItemI];
while (queue.length > 0) { /** @type {NodeState} */
let next = queue.splice(0, 1)[ 0 ]; let node = nodes[nodeI];
if (next instanceof Array) queue.splice(0, 0, ...next); let nodeKey = oldVItem.vnode?.props?.key
else { if(nodeKey !== undefined){
let meta = getNodeMeta(next); keyedChildren.set(nodeKey, oldVItem);
if (meta && meta.renderer) { }
// Only items with a renderer are tracked (any other are undefined or null and shoulnd't be rendered at all) ++oldVItemI;
let childType = meta.normedType; if(oldVItem.meta.isNode && node){
if (!meta.renderer.remove) childType = 'node'; // Treat anything that doesnt have a special remove-function as ChildNode-type (e.g. it shows up in Element.childNodes) oldVItem.node = node;
childTypes.add(childType);// Track that children of this type exist and should be iterated later node.old = oldVItem;
vChildren[ childType ] = vChildren[ childType ] || []; // Make sure the array exists ++nodeI;
vChildren[ childType ].push({ }else if(nodeKey === undefined){
specialOldVChildren.push(oldVItem);
}
}
// Any items not part of the old-vnode spec should be left alone, and ignored in further processing
nodesLength = nodeI; // TODO this feels dirty, but not having this here will screw up our examples, we have yet to add a proper test
// Iterate new children, remove old nodes as needed and create new queue items
/** @type {VRenderQueueItem[]} */
let queueChildren = [];
/** @type {VRenderItem} */
let previousNodeItem = undefined;
let vItemI = 0, vItemLength = vChildren.length;
nodeI = 0;
while(vItemI < vItemLength || nodeI < nodesLength){
let vItem = vChildren[vItemI];
let node = nodes[nodeI];
if(vItem){
let nodeKey = vItem?.vnode?.props?.key;
/** @type {FlattenedOldVNode} */
let oldItem = undefined;
if(nodeKey!==undefined){
oldItem = keyedChildren.get(nodeKey);
}
/** @type {NodeState} */
let matchedNode = undefined;
// If this is a DOM-node-type match it to an existing node
if(vItem.meta.isNode) {
// Match new item to existing node when possible
matchedNode = oldItem?.node;
if (matchedNode) {
// DOM-node matched by key
if (matchedNode === node) {
// Item in order with old representation
++nodeI;
} else {
// Out of order, detach
if (matchedNode.attached) {
item.host.removeChild(matchedNode.node);
matchedNode.attached = false;
}
}
} else if (node) {
// No match by key
// If old VNode associated, check if updatable
if (node.old
&& node.old.vnode?.props?.key === undefined
&& node.old.meta.normedType === vItem.meta.normedType
) {
oldItem = node.old;
matchedNode = node;
++nodeI;
} else {
// Not updatable, detach
if (node.attached) {
item.host.removeChild(node.node);
node.attached = false;
}
}
}
}else{
// If this is a special type and it was not matched to an old VNode by key, find it the first non-keyed
// old VNode with the same normedType (These are ShadowDOM or Host-types)
if(!oldItem){
let indexOf = specialOldVChildren.findIndex(old=>old.meta.normedType===vItem.meta.normedType);
if(indexOf>=0){
oldItem = specialOldVChildren[indexOf];
specialOldVChildren.splice(indexOf,1);
}
}
}
// Track that the node is used by a new node (we don't use this anywhere?!)
if(matchedNode){
matchedNode.new = vItem;
}
/** @type {VRenderQueueItem} */
let queueItem = {
item: { item: {
...item, vnode: vItem.vnode,
old: undefined, document: item.document,
vnode: next, host: matchedNode?.node,
host: undefined, old: oldItem?.vnode,
parent: item.host inSvg: item.inSvg,
parent: item,
}, },
meta: meta attached: matchedNode?.attached,
}); previousNode: previousNodeItem,
meta: vItem.meta
} }
queueChildren.push(queueItem);
if(vItem.meta.isNode){
previousNodeItem = queueItem.item;
}
++vItemI;
}else if(node){
// Trailing node item
if(node.attached && !node.new) {
// If this node was not reused for a new vnode representation, and was not already detached
// for other reasons, remove it
item.host.removeChild(node.node);
node.attached = false;
}
++nodeI;
} }
} }
// Flatten and organize old-children state.queue.unshift(...queueChildren);
/**
* @type { Object.<VNodeType, Array.<VOldQueueItem>> } // Remove any special types (Host, ShadowDOM that were no longer present)
*/ for(let oldSpecialVnode of specialOldVChildren){
let oldVChildren = {}; oldSpecialVnode.meta.renderer.remove?.(oldSpecialVnode.vnode);
let curElement = item.host.firstChild;
queue = (item.old?.children || []).slice();
while (queue.length > 0) {
let next = queue.splice(0, 1)[ 0 ];
if (next instanceof Array) queue.splice(0, 0, ...next);
else {
let meta = getNodeMeta(next);
if (meta && meta.renderer) {
// Only items with a renderer are tracked (any other are undefined or null and shoulnd't be rendered at all)
let childType = meta.normedType;
let childElement;
if (!meta.renderer.remove) {
childType = 'node';// Treat anything that doesnt have a special remove-function as ChildNode-type (e.g. it shows up in Element.childNodes)
if (curElement) {
childElement = curElement;
curElement = curElement.nextSibling;
}
}
childTypes.add(childType);// Track that children of this type exist and should be iterated later
oldVChildren[ childType ] = oldVChildren[ childType ] || []; // Make sure the array exists
let oldItem = {
vnode: next,
element: childElement,
meta: meta
};
oldVChildren[ childType ].push(oldItem);
if (next.props?.key) {
state.keyedElements.set(next.props?.key, oldItem);
}
}
} }
} }
let sortedChildTypes = Array.from(childTypes).sort((a, b) => a === 'node' ? 1 : -1); // Always do ChildNode-types last if (!attached && meta.isNode) {
let queuedItems = []; if (previousNode) {
/**@type {VRenderQueueItem}*/ let previous = null;
for (let childType of sortedChildTypes) {
let newChildren = vChildren[ childType ];
let oldChildren = oldVChildren[ childType ];
while (newChildren && newChildren.length) {
let child = newChildren.splice(0, 1)[ 0 ];
// Key handling
let childKey = child.item.vnode.props?.key;
/**@type {VOldQueueItem}*/ let oldChild;
if (childKey) {
oldChild = state.keyedElements.get(childKey);
if (oldChild) {
if (oldChildren && oldChildren[ 0 ] === oldChild) {
// Old keyed child already in the right place (just clear it from the queue);
oldChildren.splice(0, 1);
} else {
// Old keyed child not already in the right place
let indexOfKeyed = oldChildren.indexOf(oldChild);
if (indexOfKeyed) {
oldChildren.splice(indexOfKeyed, 1);
if(oldChild.element) {
item.host.removeChild(oldChild.element);
}else{
// This apparantly happens
}
}
if (previous) {
previous.host.after(oldChild.element);
} else {
//item.parent.prepend(oldChild.element);
item.host.prepend(oldChild.element);
}
}
}
}
if (!oldChild) oldChild = oldChildren && oldChildren.splice(0, 1)[ 0 ];
child.previous = previous;
if (oldChild && child.meta.normedType === oldChild.meta.normedType && childKey === oldChild.vnode.props?.key
&& (child.meta.normedType !== Node || child.item.vnode === oldChild.vnode)) {
// Update old-child
child.item.host = oldChild.element;
child.item.old = oldChild.vnode;
queuedItems.push(child);
} else {
// New child
if (oldChild) {
if(oldChild.element) {
if (oldChild.meta.renderer.remove){
oldChild.meta.renderer.remove({
...item,
parent: item.host,
host: oldChild.element
});
}else {
item.host.removeChild(oldChild.element);
}
}else{
// This apparantly happens
}
}
queuedItems.push(child);
}
if (!child.meta.renderer.remove) {
// If child is a node-type item track it as the previous (so we can insert next node-type items after it as intended)
previous = child.item;
}
}
while (oldChildren && oldChildren.length) {
let oldChild = oldChildren.splice(0, 1)[ 0 ];
if(oldChild.element) {
if (oldChild.meta.renderer.remove) {
oldChild.meta.renderer.remove({ ...item, parent: item.host, host: oldChild.element });
} else {
item.host.removeChild(oldChild.element);
}
}else{
// This apparantly happens
}
}
}
state.queue.splice(0, 0, ...queuedItems);
}
if (newlyCreated) {
if (!meta.renderer.remove) {
if (item.parent) {
if (!previous) {
// First child
item.parent.prepend(item.host);
} else {
// Subsequent child // Subsequent child
previous.host.after(item.host); previousNode.host.after(item.host);
} else if (item.parent) {
// First child
item.parent.host.prepend(item.host);
} }
} }
} if(!item.parent && !newRoot){
if (!item.parent) newRoot = item.host; newRoot = item.host;
} }
} }
@ -266,3 +235,46 @@ export function render(vnode, opts = {}) {
} }
return newRoot; return newRoot;
} }
/**
* Intermediate type used to process child-items
* @typedef {object} FlattenedVNode
* @property {VNode} vnode
* @property {VNodeMeta} meta
*/
/**
* @typedef {object} NodeState
* @property {ChildNode} node
* @property {boolean} attached
* @property {FlattenedOldVNode} old
* @property {FlattenedVNode} new
*/
/**
* Intermediate type used to process old child-items
* @typedef {FlattenedVNode} FlattenedOldVNode
* @property {NodeState} node - The DOM-node
*/
/**
*
* @param {VNode} vnode
* @returns {FlattenedVNode[]}
*/
function flattenVNodeChildren(vnode){
let children = [];
let queue = (vnode?.children || []).slice();
while (queue.length > 0) {
let next = queue.shift();
if (next instanceof Array) queue.splice(0, 0, ...next);
else {
let meta = getNodeMeta(next);
if (meta) {
children.push({ vnode: next, meta });
}else{
// null or undefined child, ignored
}
}
}
return children;
}

View File

@ -10,12 +10,22 @@ export const Host = Symbol('Host');
* @implements {VNodeRenderer} * @implements {VNodeRenderer}
*/ */
export const HostNodeRenderer = { export const HostNodeRenderer = {
/**
* @return {VNodeRendererMeta}
*/
meta(){
return {
hasChildren: true,
isNode: true, // Host node should always
};
},
/** /**
* @param {VRenderItem} item * @param {VRenderItem} item
*/ */
create(item){ create(item){
if(!item.parent) throw new Error("Host node cannot appear as a top-level element unless a parent is provided"); if(!item.parent.host) throw new Error("Host node cannot appear as a top-level element unless a parent is provided");
else return item.parent; else return item.parent.host;
}, },
/** /**
@ -31,7 +41,7 @@ export const HostNodeRenderer = {
* @param {VRenderState} state * @param {VRenderState} state
*/ */
update(item, state){ update(item, state){
item.host = item.host || item.parent; item.host = item.host || item.parent.host;
NodeTreeRenderer.update(item,state); NodeTreeRenderer.update(item,state);
}, },
}; };

View File

@ -7,6 +7,16 @@ import '../types';
* @implements {VNodeRenderer} * @implements {VNodeRenderer}
*/ */
export const NativeRenderer = { export const NativeRenderer = {
/**
* @return {VNodeRendererMeta}
*/
meta(){
return {
hasChildren: false,// Counter intiutitive, but this is false as CSX will never manage children of a premade DOM-element
isNode: true,
};
},
/** /**
* @param {VRenderItem} item * @param {VRenderItem} item
*/ */
@ -18,6 +28,9 @@ export const NativeRenderer = {
* @param {VRenderItem} item * @param {VRenderItem} item
*/ */
update(item){ update(item){
return;// NO-OP if(item.old && item.old!==item.vnode){
item.host.replaceWith(item.vnode);
}
return;
} }
}; };

View File

@ -9,6 +9,16 @@ export const Primitive = Symbol("primitive");
* @implements {VNodeRenderer} * @implements {VNodeRenderer}
*/ */
export const PrimitiveRenderer = { export const PrimitiveRenderer = {
/**
* @return {VNodeRendererMeta}
*/
meta(){
return {
hasChildren: false,// Primitive type (TextNode) can't have children
isNode: true,
};
},
/** /**
* @param {VRenderItem} item * @param {VRenderItem} item
*/ */

View File

@ -12,6 +12,16 @@ let namespace = {
* @implements {VNodeRenderer} * @implements {VNodeRenderer}
*/ */
export const NodeTreeRenderer = { export const NodeTreeRenderer = {
/**
* @return {VNodeRendererMeta}
*/
meta(){
return {
hasChildren: true,
isNode: true,
};
},
/** /**
* *
* @param {VRenderItem} item * @param {VRenderItem} item

View File

@ -9,12 +9,22 @@ export const ShadowDOM = Symbol('ShadowDOM');
* @implements {VNodeRenderer} * @implements {VNodeRenderer}
*/ */
export const ShadowNodeRenderer = { export const ShadowNodeRenderer = {
/**
* @return {VNodeRendererMeta}
*/
meta(){
return {
hasChildren: true,
isNode: false,
};
},
/** /**
* @param {VRenderItem} item * @param {VRenderItem} item
*/ */
create(item) { create(item) {
if (!item.parent) throw new Error("ShadowDOM node cannot appear as a top-level element unless a parent is provided"); if (!item.parent) throw new Error("ShadowDOM node cannot appear as a top-level element unless a parent is provided");
else return item.parent.shadowRoot || item.parent.attachShadow({ mode: 'open' });// TODO Pass props as options? (e.g. delegateFocus, mode) else return item.parent.host.shadowRoot || item.parent.host.attachShadow({ mode: 'open' });// TODO Pass props as options? (e.g. delegateFocus, mode)
}, },
/** /**
@ -28,6 +38,6 @@ export const ShadowNodeRenderer = {
* @param {VRenderItem} item * @param {VRenderItem} item
*/ */
update(item) { update(item) {
item.host = item.host || item.parent.shadowRoot; item.host = item.host || item.parent.host.shadowRoot;
}, },
}; };

View File

@ -2,3 +2,4 @@ export * from "./vnode";
export * from "./render-item"; export * from "./render-item";
export * from "./render-state"; export * from "./render-state";
export * from "./vnode-renderer"; export * from "./vnode-renderer";
export * from "./renderer-meta";

View File

@ -2,13 +2,12 @@ import './vnode';
/** /**
* Per node rendering-state when rendering a tree of VNodes * Per node rendering-state when rendering a tree of VNodes
* @typedef VRenderItem * @interface VRenderItem
* @interface
* @category VDOM.renderer * @category VDOM.renderer
* @property {VNode} vnode - The VNode representation to update to * @property {VNode} vnode - The VNode representation to update to
* @property {VNode} [old] - The previous VNode representation of this item * @property {VNode} [old] - The previous VNode representation of this item
* @property {Element} host - The DOM-node being rendered * @property {Element} host - The DOM-node being rendered
* @property {Document} document - The DOM-document to be added to * @property {Document} document - The DOM-document to be added to
* @property {boolean} inSvg - Indicates whether this node is a child of an SVG element, and should thus be created with createElementNS(...) * @property {boolean} inSvg - Indicates whether this node is a child of an SVG element, and should thus be created with createElementNS(...)
* @property {Element} [parent] - Parent DOM-node * @property {VRenderItem} [parent] - Parent render item
**/ **/

View File

@ -6,8 +6,7 @@ import "./vnode";
/** /**
* Per node rendering-state when rendering a tree of VNodes * Per node rendering-state when rendering a tree of VNodes
* @typedef VRenderQueueItemMetadata * @typedef {object} VRenderQueueItemMetadata
* @interface
* @category VDOM.renderer * @category VDOM.renderer
* @property {VNodeRenderer} renderer - The renderer that will render this item * @property {VNodeRenderer} renderer - The renderer that will render this item
* @property {VNodeType} normedType - The normed type of a VNode, for most VNode this just maps to vnode.type, but a text-node normally does not have a type. * @property {VNodeType} normedType - The normed type of a VNode, for most VNode this just maps to vnode.type, but a text-node normally does not have a type.
@ -16,30 +15,28 @@ import "./vnode";
/** /**
* Per node rendering-state when rendering a tree of VNodes * Per node rendering-state when rendering a tree of VNodes
* @typedef VRenderQueueItem * @typedef {object} VRenderQueueItem
* @interface
* @category VDOM.renderer * @category VDOM.renderer
* @property {VRenderItem} item - The item to queue for rendering * @property {VRenderItem} item - The item queued for rendering
* @property {VRenderQueueItemMetadata} meta - Meta data for the item such as normedType and the renderer to use(from a preprocessing stage) * @property {VRenderQueueItemMetadata} meta - Meta data for the item such as normedType and the renderer to use(from a preprocessing stage)
* @property {VRenderItem} previous - The item that will have been inserted before this one * @property {VRenderItem} [previousNode] - The previous node-item that will have been inserted before this one
* @property {boolean} [attached] - Whether the host-node was already attached to the dom
**/ **/
/** /**
* Temporary data structure for listing an old VNode * Temporary data structure for listing an old VNode
* @typedef VOldQueueItem * @typedef {object} VOldQueueItem
* @interface
* @category VDOM.renderer * @category VDOM.renderer
* @property {VNode} vnode - The old vnode * @property {VNode} vnode - The old vnode
* @property {VRenderQueueItemMetadata} meta - Meta data for the item such as normedType and the renderer to use(from a preprocessing stage) * @property {VRenderQueueItemMetadata} meta - Meta data for the item such as normedType and the renderer to use(from a preprocessing stage)
* @property {Element} element - The matching element * @property {Element} element - The matching element
* @property {boolean} detached - Indicates if the element was already removed
**/ **/
/** /**
* Global rendering-state when rendering a tree of VNodes * Global rendering-state when rendering a tree of VNodes
* @typedef VRenderState * @typedef {object} VRenderState
* @interface
* @category VDOM.renderer * @category VDOM.renderer
* @property {Array.<VRenderQueueItem>} queue - The queue of items to be rendered * @property {Array.<VRenderQueueItem>} queue - The queue of items to be rendered
* @property {Array.<[Function,Element]>} refs - Ref-callback functions be called when rendering is done * @property {Array.<[Function,Element]>} refs - Ref-callback functions be called when rendering is done
* @property {Map.<string, VOldQueueItem>} keyedElements - A map of (old) keyed elements
**/ **/

View File

@ -0,0 +1,9 @@
import './vnode';
/**
* Per node rendering-state when rendering a tree of VNodes
* @interface VNodeRendererMeta
* @category VDOM.renderer
* @property {boolean} isNode - Whether the node-type handled by this renderer has a place in the DOM hierarchy
* @property {boolean} hasChildren - Indicates if the node-type handled by this renderer can have child-nodes
**/

View File

@ -1,18 +1,26 @@
import "./render-item";// Info about what we're rendering and where to import "./render-item";// Info about what we're rendering and where to
import "./renderer-meta"; // Meta-data about the type of node handled
// Note: This type is not meant to be public // Note: This type is not meant to be public
/** /**
* Represents a renderer capable of rendering a VNode of a certain type * Represents a renderer capable of rendering a VNode of a certain type
* @interface VNodeRenderer * @interface VNodeRenderer
* @class
**/ **/
/**
* This method creates the element corresponding to a vnode
* @method
* @name VNodeRenderer#meta
* @returns {VNodeRendererMeta}
*/
/** /**
* This method creates the element corresponding to a vnode * This method creates the element corresponding to a vnode
* @method * @method
* @name VNodeRenderer#create * @name VNodeRenderer#create
* @param {VRenderItem} item * @param {VRenderItem} item
* @param {VRenderItem} parent
* @returns {Element} * @returns {Element}
*/ */

View File

@ -18,8 +18,7 @@
/** /**
* A tree of virtual-nodes (e.g, type,props,attr and nested children) * A tree of virtual-nodes (e.g, type,props,attr and nested children)
* @typedef VNodeTree * @interface VNodeTree
* @interface
* @category VDOM * @category VDOM
* @property {VNodeType} type - TagName or CustomElement of the html-element * @property {VNodeType} type - TagName or CustomElement of the html-element
* @property {VNodeProps} props - Properties to set on the element * @property {VNodeProps} props - Properties to set on the element