diff --git a/README.md b/README.md index ef456fd..b446ca3 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -TODO the root build scripts are yet to be updated (building/watching and serving examples...) \ No newline at end of file +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 \ No newline at end of file diff --git a/examples/index.html b/examples/index.html index 5029a86..fc2263d 100644 --- a/examples/index.html +++ b/examples/index.html @@ -27,6 +27,9 @@
  • Tables (arrow functions)
  • +
  • + Tables 2 (Key-prop) +
  • \ No newline at end of file diff --git a/examples/table-2/index.html b/examples/table-2/index.html new file mode 100644 index 0000000..a2aa127 --- /dev/null +++ b/examples/table-2/index.html @@ -0,0 +1,10 @@ + + + + + Cerxes - CustomElements - SVG + + + + + \ No newline at end of file diff --git a/examples/table-2/index.jsx b/examples/table-2/index.jsx new file mode 100644 index 0000000..c1cb879 --- /dev/null +++ b/examples/table-2/index.jsx @@ -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 () + }, + size: 110 + }, + { + headerRender: () => "Down", + render: (u) => { + let d = new Date(); + return () + }, + size: 110 + }, + ]; + + @state() + users = []; + + rowKey = (user)=>user.userId; + cellRef = (user, colIdx, el)=>user.cells[colIdx]=el; + + render(){ + + return + } + + 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()); +document.body.appendChild(render()); diff --git a/examples/table-2/index.scss b/examples/table-2/index.scss new file mode 100644 index 0000000..1229903 --- /dev/null +++ b/examples/table-2/index.scss @@ -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; +} \ No newline at end of file diff --git a/examples/table-2/table-component.jsx b/examples/table-2/table-component.jsx new file mode 100644 index 0000000..cf8b38b --- /dev/null +++ b/examples/table-2/table-component.jsx @@ -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 ( + + + +
    +
    +
    + {this.#columnDefinitions.map((col, idx) => ( +
    + {col.headerRender()} +
    + ))} +
    +
    +
    + {this.data?.map((dataRow,index) => ( +
    this.rows.set(this.rowKey(dataRow,index), el)}> + {this.#columnDefinitions.map((col, idx) => ( +
    this.cellRef(dataRow,idx,el)}> + {col.render(dataRow)} +
    + ))} +
    + ))} +
    +
    +
    + ); + } +} \ No newline at end of file diff --git a/examples/table-2/table-component.scss b/examples/table-2/table-component.scss new file mode 100644 index 0000000..d3c2895 --- /dev/null +++ b/examples/table-2/table-component.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/jest/components/shadow-dom.test.js b/jest/components/shadow-dom.test.js new file mode 100644 index 0000000..1ea73d0 --- /dev/null +++ b/jest/components/shadow-dom.test.js @@ -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 ( + + +
    +

    {this.title}

    + +
    +
    +
    + ) + } + } + + let initialVSpec = ; + 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([ + ``, + ``, + ].join('')); + + expect( + rendered.shadowRoot.innerHTML + ).toBe([ + `
    `, + `

    Content here

    `, + ``, + `
    `, + ].join('')); + + // Update behaves as it should + let updatedVSpec = ( + +
    • contents
  • +
    + ); + render(updatedVSpec, {host: rendered, old: initialVSpec}); + + // Wait for it to update + await nextAnimationFrame(); + + expect( + container.innerHTML + ).toBe([ + ``, + `
    • contents
  • `, + `
    `, + ].join('')); + + expect( + rendered.shadowRoot.innerHTML + ).toBe([ + `
    `, + `

    New content here

    `, + ``, + `
    `, + ].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 ( + + + + + + + ) + } + } + + @defineElement('my-todo') + class MyTodo extends CustomElement { + @state() + todos = [ + { text: "todo 1", checked: true }, + { text: "todo 2", checked: false }, + ]; + + rendered = []; + + render(){ + return ( + +

    Todos

    +
      + {this.todos.map((todo,index)=>( + this.rendered[index]=el} + > + {todo.text} + + ))} +
    +
    + ) + } + } + + let initialVSpec = ; + 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([ + ``, + `

    Todos

    `, + `
      `, + ...rendered.todos.map(todo=>( + `${todo.text}` + )), + `
    `, + `
    `, + ].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([ + ``, + `` + ].join('')); + } + + document.body.removeChild(container); + }); + +}); \ No newline at end of file diff --git a/jest/components/svg.test.js b/jest/components/svg.test.js new file mode 100644 index 0000000..536b79f --- /dev/null +++ b/jest/components/svg.test.js @@ -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": [ + ``, + `checkbox-checked`, + ``, + ``, + `` + ].join(''), + "/assets/icons/checkbox.svg": [ + ``, + `checkbox`, + ``, + `` + ].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} + */ + 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 ( + + + +
    + {this.svgElement} +
    +
    +
    + ); + } + } + + let initialVSpec = + 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([ + ``, + ``, + ].join('')); + + await new Promise((resolve,reject)=>setTimeout(()=>resolve(),10)); + await nextAnimationFrame(); + + expect( + rendered.shadowRoot.innerHTML + ).toBe([ + ``, + `
    `, + svgs["/assets/icons/checkbox.svg"], + `
    ` + ].join('')); + + let updatedVSpec = ; + render(updatedVSpec, {host: rendered, old: initialVSpec}); + + await new Promise((resolve,reject)=>setTimeout(()=>resolve(),10)); + await nextAnimationFrame(); + + expect( + rendered.shadowRoot.innerHTML + ).toBe([ + ``, + `
    `, + svgs["/assets/icons/checkbox-checked.svg"], + `
    ` + ].join('')); + + document.body.removeChild(container); + }); +}); \ No newline at end of file diff --git a/jest/render/basic-render.test.js b/jest/render/basic-render.test.js index e32fe96..0d8d18f 100644 --- a/jest/render/basic-render.test.js +++ b/jest/render/basic-render.test.js @@ -194,13 +194,14 @@ describe("Children", () => { ].join('')); }); - test("Nulls are ignored", async () => { + test("Nulls and undefined are ignored", async () => { expect( testContainer( render(

    Title

    {null} + {undefined}
    ) @@ -212,4 +213,50 @@ describe("Children", () => { `` ].join('')); }); + + test("Update maintains the same elements where possible", async () => { + let initialVSpec = ( +
    +

    Title

    + +
    + ); + let rendered = render(initialVSpec); + let container = testContainer(rendered); + + let children = Array.from(rendered.childNodes);// Capture current child-nodes + + expect( + container.innerHTML + ).toBe([ + `
    `, + `

    Title

    `, + ``, + `
    ` + ].join('')); + + // Update it + let updatedVSpec = ( +
    +

    Update

    + +
    + ); + render(updatedVSpec, {host: rendered, old: initialVSpec}); + expect( + container.innerHTML + ).toBe([ + `
    `, + `

    Update

    `, + ``, + `
    ` + ].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 + } + }); }); \ No newline at end of file diff --git a/jest/render/key-property.test.js b/jest/render/key-property.test.js index 9624320..efc29bc 100644 --- a/jest/render/key-property.test.js +++ b/jest/render/key-property.test.js @@ -3,14 +3,14 @@ import { testContainer } from "../utils/test-container"; describe("Key-property tests", () => { test("Keyed list", async () => { - let renderedIndexes = []; - let initIndexes = [0, 1, 2, 3]; + let initialRendered = new Map(); + let initIndexes = [1, 2, 3, 4]; let makeSpec = (targetList, indexes)=>(
      { indexes.map(index => ( -
    • targetList[ index ] = el}> +
    • targetList.set(index, el)}> {index}
    • )) @@ -18,7 +18,7 @@ describe("Key-property tests", () => {
    ); - let initialVSpec = makeSpec(renderedIndexes, initIndexes); + let initialVSpec = makeSpec(initialRendered, initIndexes); let rendered = render(initialVSpec); let container = testContainer(rendered); @@ -31,17 +31,18 @@ describe("Key-property tests", () => { ].join('') ); - expect(renderedIndexes.length).toBe(4); - for(let rendered of renderedIndexes){ + expect(initialRendered.size).toBe(4); + for(let rendered of initialRendered){ expect(rendered).not.toBeUndefined(); } - let reorderedIndexes = [3,2,1,0]; - let rerenderedIndexes = renderedIndexes.slice(); + // Reverse order + let reorderedIndexes = [4,3,2,1]; + let rerenderedIndexes = new Map(initialRendered); 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( [ `
      `, @@ -51,11 +52,46 @@ describe("Key-property tests", () => { ); // Validate that items were merely re-arranged and not re-created - expect(rerenderedIndexes.length).toBe(4); - for(let i=0; i<4; ++i){ - let initRendered = renderedIndexes[i]; - let reorderedRendered = rerenderedIndexes[i]; + expect(rerenderedIndexes.size).toBe(4); + for(let i of initIndexes){ + let initRendered = initialRendered.get(i); + let reorderedRendered = rerenderedIndexes.get(i); 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( + [ + `
        `, + ...additionalIndexes.map(index=>`
      • ${index}
      • `), + `
      ` + ].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( + [ + `
        `, + ...initIndexes.map(index=>`
      • ${index}
      • `), + `
      ` + ].join('') + ); }); }); \ No newline at end of file diff --git a/jest/render/svg-rendering.test.js b/jest/render/svg-rendering.test.js new file mode 100644 index 0000000..e2543ea --- /dev/null +++ b/jest/render/svg-rendering.test.js @@ -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) => ( + + + + + + + + + + + ) + let initialVSpec = makeSpec("#000", 2); + let rendered = render(initialVSpec); + let container = testContainer(rendered); + + expect(container.innerHTML).toBe([ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `` + ].join('')); + + let updatedVSpec = makeSpec("#FFF", 4); + render(updatedVSpec, { host: rendered, old: initialVSpec }); + + expect(container.innerHTML).toBe([ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `` + ].join('')); + }); +}); \ No newline at end of file diff --git a/packages/csx/.npmignore b/packages/csx/.npmignore index b15d472..cd5dfbd 100644 --- a/packages/csx/.npmignore +++ b/packages/csx/.npmignore @@ -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/ -src/* node_modules/* rollup.config.js yarn.lock \ No newline at end of file diff --git a/packages/csx/cfg/config.js b/packages/csx/cfg/config.js index 0666fe9..5646fd5 100644 --- a/packages/csx/cfg/config.js +++ b/packages/csx/cfg/config.js @@ -110,7 +110,7 @@ export class CsxConfig { let srcOpts = { es: format==='es', single: type==='lib', - sourcemap: true,// Just always there for now + sourcemap: minified ? true : (format==='es'? false : true), minified: !!minified }; let outDir = type==='lib'? lib : dist; diff --git a/packages/csx/package.json b/packages/csx/package.json index 8d011d0..82fce1e 100644 --- a/packages/csx/package.json +++ b/packages/csx/package.json @@ -1,6 +1,6 @@ { "name": "@cerxes/csx", - "version": "0.0.11", + "version": "0.0.13", "author": "Miel Truyen ", "description": "CSX is a minimalistic UI-framework inspired by React+JSX for usage with WebComponents.", "repository": { diff --git a/packages/csx/src/vdom/node-meta.js b/packages/csx/src/vdom/node-meta.js new file mode 100644 index 0000000..8e38b1a --- /dev/null +++ b/packages/csx/src/vdom/node-meta.js @@ -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 + }; + } +} \ No newline at end of file diff --git a/packages/csx/src/vdom/render.js b/packages/csx/src/vdom/render.js index 4fc11ef..1d0ea27 100644 --- a/packages/csx/src/vdom/render.js +++ b/packages/csx/src/vdom/render.js @@ -1,71 +1,53 @@ import './types'; -import { - 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?) - */ +import { getNodeMeta } from "./node-meta"; /** * This exists as a very basic example/test for JSX-to-DOM * @category VDOM * @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} */ 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) // 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) // or are some other special type of node (like Host or ShadowDOM) - /** - * - * @type {VRenderState} - */ + /** @type {VRenderState} */ let state = { - keyedElements: new Map(), refs: [], queue: [{ // Start item item: { document: opts.document || document, host: opts.host, - parent: opts.parent, old: opts.old, vnode: vnode }, - meta: getNodeMeta(vnode) + meta: getNodeMeta(vnode), + parent: null, + attached: true// Root item is treated as if it is attached }] }; let newRoot = undefined; 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; 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; else if (item.inSvg && item.vnode?.type === 'foreignObject') item.inSvg = false; @@ -74,8 +56,9 @@ export function render(vnode, opts = {}) { if (!item.host) { item.host = renderer.create(item, meta); 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]); } } @@ -84,180 +67,166 @@ export function render(vnode, opts = {}) { renderer.update(item, meta); // Update children - if (meta.normedType !== Node && (item.vnode?.children || item.old?.children)) { - let childTypes = new Set(); + if (meta.hasChildren) { + /** @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) - /** - * @type { Object.> } - */ - let vChildren = {}; - let queue = (item.vnode?.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; - 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) - childTypes.add(childType);// Track that children of this type exist and should be iterated later - vChildren[ childType ] = vChildren[ childType ] || []; // Make sure the array exists - vChildren[ childType ].push({ - item: { - ...item, - old: undefined, - vnode: next, - host: undefined, - parent: item.host - }, - meta: meta - }); - } + // Iterate old children and link them to their corresponding elements + let nodeI = 0, nodesLength = nodes.length; + let oldVItemI = 0, oldVItemLength = oldVChildren.length; + while(oldVItemI < oldVItemLength){ + /** @type {FlattenedOldVNode} */ + let oldVItem = oldVChildren[oldVItemI]; + /** @type {NodeState} */ + let node = nodes[nodeI]; + let nodeKey = oldVItem.vnode?.props?.key + if(nodeKey !== undefined){ + keyedChildren.set(nodeKey, oldVItem); + } + ++oldVItemI; + if(oldVItem.meta.isNode && node){ + oldVItem.node = node; + node.old = oldVItem; + ++nodeI; + }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 - // Flatten and organize old-children - /** - * @type { Object.> } - */ - let oldVChildren = {}; - 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 - let queuedItems = []; - /**@type {VRenderQueueItem}*/ let previous = null; - for (let childType of sortedChildTypes) { - let newChildren = vChildren[ childType ]; - let oldChildren = oldVChildren[ childType ]; + // 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]; - while (newChildren && newChildren.length) { - let child = newChildren.splice(0, 1)[ 0 ]; + if(vItem){ + let nodeKey = vItem?.vnode?.props?.key; + /** @type {FlattenedOldVNode} */ + let oldItem = undefined; + if(nodeKey!==undefined){ + oldItem = keyedChildren.get(nodeKey); + } - // 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); + /** @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 { - // 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); + // Out of order, detach + if (matchedNode.attached) { + item.host.removeChild(matchedNode.node); + matchedNode.attached = false; } } - } - } - 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 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{ - // 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 + // 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: { + vnode: vItem.vnode, + document: item.document, + host: matchedNode?.node, + old: oldItem?.vnode, + inSvg: item.inSvg, + parent: item, + }, + 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; } } - state.queue.splice(0, 0, ...queuedItems); + state.queue.unshift(...queueChildren); + + // Remove any special types (Host, ShadowDOM that were no longer present) + for(let oldSpecialVnode of specialOldVChildren){ + oldSpecialVnode.meta.renderer.remove?.(oldSpecialVnode.vnode); + } } - if (newlyCreated) { - if (!meta.renderer.remove) { - if (item.parent) { - if (!previous) { - // First child - item.parent.prepend(item.host); - } else { - // Subsequent child - previous.host.after(item.host); - } - } + if (!attached && meta.isNode) { + if (previousNode) { + // Subsequent child + previousNode.host.after(item.host); + } else if (item.parent) { + // First child + item.parent.host.prepend(item.host); } - if (!item.parent) newRoot = item.host; + } + if(!item.parent && !newRoot){ + newRoot = item.host; } } @@ -266,3 +235,46 @@ export function render(vnode, opts = {}) { } 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; +} \ No newline at end of file diff --git a/packages/csx/src/vdom/renderers/hostnode.js b/packages/csx/src/vdom/renderers/hostnode.js index 48401c5..d06ad05 100644 --- a/packages/csx/src/vdom/renderers/hostnode.js +++ b/packages/csx/src/vdom/renderers/hostnode.js @@ -10,12 +10,22 @@ export const Host = Symbol('Host'); * @implements {VNodeRenderer} */ export const HostNodeRenderer = { + /** + * @return {VNodeRendererMeta} + */ + meta(){ + return { + hasChildren: true, + isNode: true, // Host node should always + }; + }, + /** * @param {VRenderItem} item */ create(item){ - if(!item.parent) throw new Error("Host node cannot appear as a top-level element unless a parent is provided"); - else return item.parent; + 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.host; }, /** @@ -31,7 +41,7 @@ export const HostNodeRenderer = { * @param {VRenderState} state */ update(item, state){ - item.host = item.host || item.parent; + item.host = item.host || item.parent.host; NodeTreeRenderer.update(item,state); }, }; \ No newline at end of file diff --git a/packages/csx/src/vdom/renderers/nativeelement.js b/packages/csx/src/vdom/renderers/nativeelement.js index 009d22b..a3981e2 100644 --- a/packages/csx/src/vdom/renderers/nativeelement.js +++ b/packages/csx/src/vdom/renderers/nativeelement.js @@ -7,6 +7,16 @@ import '../types'; * @implements {VNodeRenderer} */ 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 */ @@ -18,6 +28,9 @@ export const NativeRenderer = { * @param {VRenderItem} item */ update(item){ - return;// NO-OP + if(item.old && item.old!==item.vnode){ + item.host.replaceWith(item.vnode); + } + return; } }; diff --git a/packages/csx/src/vdom/renderers/nodeprimitive.js b/packages/csx/src/vdom/renderers/nodeprimitive.js index 49587f9..a3a16b4 100644 --- a/packages/csx/src/vdom/renderers/nodeprimitive.js +++ b/packages/csx/src/vdom/renderers/nodeprimitive.js @@ -9,6 +9,16 @@ export const Primitive = Symbol("primitive"); * @implements {VNodeRenderer} */ export const PrimitiveRenderer = { + /** + * @return {VNodeRendererMeta} + */ + meta(){ + return { + hasChildren: false,// Primitive type (TextNode) can't have children + isNode: true, + }; + }, + /** * @param {VRenderItem} item */ diff --git a/packages/csx/src/vdom/renderers/nodetree.js b/packages/csx/src/vdom/renderers/nodetree.js index 3c4ab30..ced9961 100644 --- a/packages/csx/src/vdom/renderers/nodetree.js +++ b/packages/csx/src/vdom/renderers/nodetree.js @@ -12,6 +12,16 @@ let namespace = { * @implements {VNodeRenderer} */ export const NodeTreeRenderer = { + /** + * @return {VNodeRendererMeta} + */ + meta(){ + return { + hasChildren: true, + isNode: true, + }; + }, + /** * * @param {VRenderItem} item diff --git a/packages/csx/src/vdom/renderers/shadownode.js b/packages/csx/src/vdom/renderers/shadownode.js index c9ff0d7..fc23eb3 100644 --- a/packages/csx/src/vdom/renderers/shadownode.js +++ b/packages/csx/src/vdom/renderers/shadownode.js @@ -9,12 +9,22 @@ export const ShadowDOM = Symbol('ShadowDOM'); * @implements {VNodeRenderer} */ export const ShadowNodeRenderer = { + /** + * @return {VNodeRendererMeta} + */ + meta(){ + return { + hasChildren: true, + isNode: false, + }; + }, + /** * @param {VRenderItem} item */ create(item) { 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 */ update(item) { - item.host = item.host || item.parent.shadowRoot; + item.host = item.host || item.parent.host.shadowRoot; }, }; diff --git a/packages/csx/src/vdom/types/index.js b/packages/csx/src/vdom/types/index.js index 3d32ca6..38764d7 100644 --- a/packages/csx/src/vdom/types/index.js +++ b/packages/csx/src/vdom/types/index.js @@ -2,3 +2,4 @@ export * from "./vnode"; export * from "./render-item"; export * from "./render-state"; export * from "./vnode-renderer"; +export * from "./renderer-meta"; \ No newline at end of file diff --git a/packages/csx/src/vdom/types/render-item.js b/packages/csx/src/vdom/types/render-item.js index 7656d09..cd592fc 100644 --- a/packages/csx/src/vdom/types/render-item.js +++ b/packages/csx/src/vdom/types/render-item.js @@ -2,13 +2,12 @@ import './vnode'; /** * Per node rendering-state when rendering a tree of VNodes - * @typedef VRenderItem - * @interface + * @interface VRenderItem * @category VDOM.renderer * @property {VNode} vnode - The VNode representation to update to * @property {VNode} [old] - The previous VNode representation of this item * @property {Element} host - The DOM-node being rendered * @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 {Element} [parent] - Parent DOM-node + * @property {VRenderItem} [parent] - Parent render item **/ diff --git a/packages/csx/src/vdom/types/render-state.js b/packages/csx/src/vdom/types/render-state.js index 9d40f0d..af8c548 100644 --- a/packages/csx/src/vdom/types/render-state.js +++ b/packages/csx/src/vdom/types/render-state.js @@ -6,8 +6,7 @@ import "./vnode"; /** * Per node rendering-state when rendering a tree of VNodes - * @typedef VRenderQueueItemMetadata - * @interface + * @typedef {object} VRenderQueueItemMetadata * @category VDOM.renderer * @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. @@ -16,30 +15,28 @@ import "./vnode"; /** * Per node rendering-state when rendering a tree of VNodes - * @typedef VRenderQueueItem - * @interface + * @typedef {object} VRenderQueueItem * @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 {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 - * @typedef VOldQueueItem - * @interface + * @typedef {object} VOldQueueItem * @category VDOM.renderer * @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 {Element} element - The matching element + * @property {boolean} detached - Indicates if the element was already removed **/ /** * Global rendering-state when rendering a tree of VNodes - * @typedef VRenderState - * @interface + * @typedef {object} VRenderState * @category VDOM.renderer * @property {Array.} queue - The queue of items to be rendered * @property {Array.<[Function,Element]>} refs - Ref-callback functions be called when rendering is done - * @property {Map.} keyedElements - A map of (old) keyed elements **/ diff --git a/packages/csx/src/vdom/types/renderer-meta.js b/packages/csx/src/vdom/types/renderer-meta.js new file mode 100644 index 0000000..e0c6d2e --- /dev/null +++ b/packages/csx/src/vdom/types/renderer-meta.js @@ -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 + **/ diff --git a/packages/csx/src/vdom/types/vnode-renderer.js b/packages/csx/src/vdom/types/vnode-renderer.js index ad27f72..f7ab928 100644 --- a/packages/csx/src/vdom/types/vnode-renderer.js +++ b/packages/csx/src/vdom/types/vnode-renderer.js @@ -1,18 +1,26 @@ 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 /** * Represents a renderer capable of rendering a VNode of a certain type * @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 * @method * @name VNodeRenderer#create * @param {VRenderItem} item + * @param {VRenderItem} parent * @returns {Element} */ diff --git a/packages/csx/src/vdom/types/vnode.js b/packages/csx/src/vdom/types/vnode.js index cd84112..d69a857 100644 --- a/packages/csx/src/vdom/types/vnode.js +++ b/packages/csx/src/vdom/types/vnode.js @@ -18,8 +18,7 @@ /** * A tree of virtual-nodes (e.g, type,props,attr and nested children) - * @typedef VNodeTree - * @interface + * @interface VNodeTree * @category VDOM * @property {VNodeType} type - TagName or CustomElement of the html-element * @property {VNodeProps} props - Properties to set on the element