diff --git a/ReadMe.md b/ReadMe.md
index acbe76f..727b49a 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -11,40 +11,69 @@ A light-weight DOM Renderer supports [Web components][1] standard & [TypeScript]
## Feature
-- input: **Virtual DOM** object in **JSX** syntax
-- output: **DOM** object or **XML** string of **HTML**, **SVG** & **MathML** languages
+- input: [Virtual DOM][7] object in [JSX][8] syntax
+- output: [DOM][9] object or [XML][10] string of [HTML][11], [SVG][12] & [MathML][13] languages
+- run as: **Sync**, [Async][14], [Generator][15] functions & [Readable streams][16]
## Usage
### JavaScript
+#### Sync Rendering
+
```js
-import { DOMRenderer } from 'dom-renderer';
+import { DOMRenderer, VNode } from 'dom-renderer';
const newVNode = new DOMRenderer().patch(
- {
+ new VNode({
tagName: 'body',
node: document.body
- },
- {
+ }),
+ new VNode({
tagName: 'body',
children: [
- {
+ new VNode({
tagName: 'a',
props: { href: 'https://idea2.app/' },
style: { color: 'red' },
- children: [{ text: 'idea2app' }]
- }
+ children: [new VNode({ text: 'idea2app' })]
+ })
]
- }
+ })
);
-
console.log(newVNode);
```
+#### Async Rendering (experimental)
+
+```diff
+import { DOMRenderer, VNode } from 'dom-renderer';
+
+-const newVNode = new DOMRenderer().patch(
++const newVNode = new DOMRenderer().patchAsync(
+ new VNode({
+ tagName: 'body',
+ node: document.body
+ }),
+ new VNode({
+ tagName: 'body',
+ children: [
+ new VNode({
+ tagName: 'a',
+ props: { href: 'https://idea2.app/' },
+ style: { color: 'red' },
+ children: [new VNode({ text: 'idea2app' })]
+ })
+ ]
+ })
+);
+-console.log(newVNode);
++newVNode.then(console.log);
+```
+
### TypeScript
-[][7]
+[][17]
#### `tsconfig.json`
@@ -59,6 +88,8 @@ console.log(newVNode);
#### `index.tsx`
+##### Sync Rendering
+
```tsx
import { DOMRenderer } from 'dom-renderer';
@@ -67,10 +98,26 @@ const newVNode = new DOMRenderer().render(
idea2app
);
-
console.log(newVNode);
```
+##### Async Rendering (experimental)
+
+```diff
+import { DOMRenderer } from 'dom-renderer';
+
+const newVNode = new DOMRenderer().render(
+
+ idea2app
+-
++ ,
++ document.body,
++ 'async'
+);
+-console.log(newVNode);
++newVNode.then(console.log);
+```
+
### Node.js & Bun
#### `view.tsx`
@@ -105,17 +152,17 @@ createServer((request, response) => {
### Web components
-[][8]
+[][18]
## Original
### Inspiration
-[][9]
+[][19]
### Prototype
-[][10]
+[][20]
[1]: https://www.webcomponents.org/
[2]: https://www.typescriptlang.org/
@@ -123,7 +170,17 @@ createServer((request, response) => {
[4]: https://github.com/EasyWebApp/DOM-Renderer/actions/workflows/main.yml
[5]: https://nodei.co/npm/dom-renderer/
[6]: https://gitpod.io/?autostart=true#https://github.com/EasyWebApp/DOM-Renderer
-[7]: https://codesandbox.io/s/dom-renderer-example-pmcsvs?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2Findex.tsx&theme=dark
-[8]: https://codesandbox.io/s/mobx-web-components-pvn9rf?autoresize=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2FWebComponent.ts&moduleview=1&theme=dark
-[9]: https://github.com/snabbdom/snabbdom
-[10]: https://codesandbox.io/s/dom-renderer-pglxkx?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2Findex.ts&theme=dark
+[7]: https://en.wikipedia.org/wiki/Virtual_DOM
+[8]: https://facebook.github.io/jsx/
+[9]: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
+[10]: https://developer.mozilla.org/en-US/docs/Web/XML
+[11]: https://developer.mozilla.org/en-US/docs/Web/HTML
+[12]: https://developer.mozilla.org/en-US/docs/Web/SVG
+[13]: https://developer.mozilla.org/en-US/docs/Web/MathML
+[14]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
+[15]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*
+[16]: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream
+[17]: https://codesandbox.io/s/dom-renderer-example-pmcsvs?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2Findex.tsx&theme=dark
+[18]: https://codesandbox.io/s/mobx-web-components-pvn9rf?autoresize=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2FWebComponent.ts&moduleview=1&theme=dark
+[19]: https://github.com/snabbdom/snabbdom
+[20]: https://codesandbox.io/s/dom-renderer-pglxkx?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&module=%2Fsrc%2Findex.ts&theme=dark
diff --git a/package.json b/package.json
index de40b48..5183f32 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dom-renderer",
- "version": "2.5.1",
+ "version": "2.6.0",
"license": "LGPL-3.0-or-later",
"author": "shiy2008@gmail.com",
"description": "A light-weight DOM Renderer supports Web components standard & TypeScript language",
@@ -25,6 +25,7 @@
"main": "dist/index.js",
"dependencies": {
"declarative-shadow-dom-polyfill": "^0.4.0",
+ "scheduler-polyfill": "^1.3.0",
"tslib": "^2.8.1",
"web-streams-polyfill": "^4.0.0",
"web-utility": "^4.4.2"
@@ -37,14 +38,14 @@
"@types/jest": "^29.5.14",
"@types/node": "^20.17.6",
"happy-dom": "^14.12.3",
- "husky": "^9.1.6",
+ "husky": "^9.1.7",
"jest": "^29.7.0",
"lint-staged": "^15.2.10",
"open-cli": "^8.0.0",
"prettier": "^3.3.3",
"ts-jest": "^29.2.5",
"typedoc": "^0.26.11",
- "typedoc-plugin-mdn-links": "^3.3.7",
+ "typedoc-plugin-mdn-links": "^3.3.8",
"typescript": "~5.6.3"
},
"prettier": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b089fe0..9e973a6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ importers:
declarative-shadow-dom-polyfill:
specifier: ^0.4.0
version: 0.4.0(typescript@5.6.3)
+ scheduler-polyfill:
+ specifier: ^1.3.0
+ version: 1.3.0
tslib:
specifier: ^2.8.1
version: 2.8.1
@@ -34,8 +37,8 @@ importers:
specifier: ^14.12.3
version: 14.12.3
husky:
- specifier: ^9.1.6
- version: 9.1.6
+ specifier: ^9.1.7
+ version: 9.1.7
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.17.6)
@@ -55,8 +58,8 @@ importers:
specifier: ^0.26.11
version: 0.26.11(typescript@5.6.3)
typedoc-plugin-mdn-links:
- specifier: ^3.3.7
- version: 3.3.7(typedoc@0.26.11(typescript@5.6.3))
+ specifier: ^3.3.8
+ version: 3.3.8(typedoc@0.26.11(typescript@5.6.3))
typescript:
specifier: ~5.6.3
version: 5.6.3
@@ -821,8 +824,8 @@ packages:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
- husky@9.1.6:
- resolution: {integrity: sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==}
+ husky@9.1.7:
+ resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
hasBin: true
@@ -1376,6 +1379,9 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+ scheduler-polyfill@1.3.0:
+ resolution: {integrity: sha512-bIjhi/KJqo08wrq+K2rlB6HNPh871KgREPpVti4zv0mSY1dCi3qr0rRCw+SGHc8/gtKceev29sN//lf6KiYa/g==}
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -1566,8 +1572,8 @@ packages:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
- typedoc-plugin-mdn-links@3.3.7:
- resolution: {integrity: sha512-iFSnYj3XPuc0wh0/VjU2M/sHtNv5pSEysUXrylHxgd5PqTAOZTUswJAcbB7shg+SfxMCqGaiyA0duNmnGs/LQg==}
+ typedoc-plugin-mdn-links@3.3.8:
+ resolution: {integrity: sha512-Aewg+SW7hBdffRpT6WnpRwWthoaF9irlzXDKRyvcDVekPZSFujOlh690SV6eCgqrtP7GBJmN0TVeJUq6+6rb1w==}
peerDependencies:
typedoc: '>= 0.23.14 || 0.24.x || 0.25.x || 0.26.x'
@@ -2601,7 +2607,7 @@ snapshots:
human-signals@5.0.0: {}
- husky@9.1.6: {}
+ husky@9.1.7: {}
ieee754@1.2.1: {}
@@ -3315,6 +3321,8 @@ snapshots:
safe-buffer@5.2.1: {}
+ scheduler-polyfill@1.3.0: {}
+
semver@6.3.1: {}
semver@7.6.3: {}
@@ -3483,7 +3491,7 @@ snapshots:
type-fest@2.19.0: {}
- typedoc-plugin-mdn-links@3.3.7(typedoc@0.26.11(typescript@5.6.3)):
+ typedoc-plugin-mdn-links@3.3.8(typedoc@0.26.11(typescript@5.6.3)):
dependencies:
typedoc: 0.26.11(typescript@5.6.3)
diff --git a/source/dist/DOMRenderer.ts b/source/dist/DOMRenderer.ts
index da99a94..cd86ddc 100644
--- a/source/dist/DOMRenderer.ts
+++ b/source/dist/DOMRenderer.ts
@@ -1,3 +1,4 @@
+import 'scheduler-polyfill';
import { ReadableStream } from 'web-streams-polyfill';
import {
diffKeys,
@@ -11,6 +12,8 @@ import {
import { DataObject, VNode } from './VDOM';
+export type RenderMode = 'sync' | 'async';
+
export interface UpdateTask {
index?: number;
oldVNode?: VNode;
@@ -24,6 +27,7 @@ export class DOMRenderer {
document = globalThis.document;
protected treeCache = new WeakMap();
+ protected signalCache = new WeakMap();
protected keyOf = ({ key, text, props, selector }: VNode, index?: number) =>
key?.toString() || props?.id || (text || selector || '') + index;
@@ -160,13 +164,15 @@ export class DOMRenderer {
(style, key, value) => style.setProperty(toHyphenCase(key), value)
);
newVNode.node ||= oldVNode.node;
+
+ return newVNode;
}
- patch(oldVRoot: VNode, newVRoot: VNode) {
+ *generateDOM(oldVRoot: VNode, newVRoot: VNode) {
if (VNode.isFragment(newVRoot))
newVRoot = new VNode({ ...oldVRoot, children: newVRoot.children });
- this.patchNode(oldVRoot, newVRoot);
+ yield this.patchNode(oldVRoot, newVRoot);
for (let { index, oldVNode, newVNode } of this.diffVChildren(oldVRoot, newVRoot)) {
if (!newVNode) {
@@ -192,20 +198,65 @@ export class DOMRenderer {
if (inserting) newVNode.ref?.(newVNode.node);
}
+ yield newVNode;
+ }
+ }
+
+ patch(oldVRoot: VNode, newVRoot: VNode) {
+ var count = 0;
+
+ for (const newVNode of this.generateDOM(oldVRoot, newVRoot))
+ if (++count === 1) newVRoot = newVNode;
+
+ return newVRoot;
+ }
+
+ async patchAsync(oldVRoot: VNode, newVRoot: VNode) {
+ const oldController = this.signalCache.get(oldVRoot.node);
+
+ if (oldController) {
+ oldController.abort();
+
+ oldVRoot = VNode.fromDOM(oldVRoot.node);
+ }
+ const controller = new AbortController();
+
+ this.signalCache.set(oldVRoot.node, controller);
+
+ var count = 0;
+
+ for (const newVNode of this.generateDOM(oldVRoot, newVRoot)) {
+ if (++count === 1) newVRoot = newVNode;
+
+ await scheduler.yield();
+
+ if (controller.signal.aborted) {
+ this.signalCache.delete(oldVRoot.node);
+
+ controller.signal.throwIfAborted();
+ }
}
+ this.signalCache.delete(oldVRoot.node);
+
return newVRoot;
}
- render(vNode: VNode, node: ParentNode = globalThis.document?.body) {
+ render(vNode: VNode, node?: ParentNode, mode?: 'sync'): VNode;
+ render(vNode: VNode, node?: ParentNode, mode?: 'async'): Promise;
+ render(
+ vNode: VNode,
+ node: ParentNode = globalThis.document?.body,
+ mode: RenderMode = 'sync'
+ ): VNode | Promise {
this.document = node.ownerDocument;
var root = this.treeCache.get(node) || VNode.fromDOM(node);
- root = this.patch(root, new VNode({ ...root, children: [vNode] }));
-
- this.treeCache.set(node, root);
+ const done = (root: VNode) => this.treeCache.set(node, root) && root;
- return root;
+ return mode === 'sync'
+ ? done(this.patch(root, new VNode({ ...root, children: [vNode] })))
+ : this.patchAsync(root, new VNode({ ...root, children: [vNode] })).then(done);
}
renderToStaticMarkup(tree: VNode) {
diff --git a/test/DOMRenderer.spec.ts b/test/DOMRenderer.spec.ts
index d128749..a260be1 100644
--- a/test/DOMRenderer.spec.ts
+++ b/test/DOMRenderer.spec.ts
@@ -1,5 +1,3 @@
-import 'declarative-shadow-dom-polyfill';
-
import { DOMRenderer, VNode } from '../source/dist';
globalThis.CDATASection = class extends Text {};
@@ -128,6 +126,46 @@ describe('DOM Renderer', () => {
expect(shadowRoot.innerHTML).toBe('');
});
+ it('should render a Virtual DOM node in Async mode', async () => {
+ const promise = renderer.render(new VNode({ tagName: 'a' }), document.body, 'async');
+
+ expect(document.body.innerHTML).not.toBe('');
+
+ await promise;
+
+ expect(document.body.innerHTML).toBe('');
+ });
+
+ it('should stop unfinished Async Rendering while new Async Rendering is started', async () => {
+ const oldTree = await renderer.render(new VNode({ tagName: 'a' }), document.body, 'async');
+
+ expect(document.body.outerHTML).toBe('');
+
+ const promise1 = renderer.patchAsync(
+ oldTree,
+ new VNode({
+ ...root,
+ props: { className: 'dark' },
+ children: [new VNode({ tagName: 'a', props: { href: '/about' } })]
+ })
+ );
+ expect(document.body.outerHTML).toBe('');
+
+ const promise2 = renderer.patchAsync(
+ oldTree,
+ new VNode({
+ ...root,
+ props: { className: 'light' },
+ children: [new VNode({ tagName: 'b' })]
+ })
+ );
+ expect(promise1).rejects.toThrow('aborted');
+
+ await promise2;
+
+ expect(document.body.outerHTML).toBe('');
+ });
+
class ShadowRootTag extends HTMLElement {
constructor() {
super();
@@ -138,6 +176,7 @@ describe('DOM Renderer', () => {
it('should render the Shadow Root to HTML strings', () => {
const markup = renderer.renderToStaticMarkup(new VNode({ tagName: 'shadow-root-tag' }));
+
expect(markup).toBe(
``
);
diff --git a/test/jsx-runtime.spec.tsx b/test/jsx-runtime.spec.tsx
index f6626c1..e34213c 100644
--- a/test/jsx-runtime.spec.tsx
+++ b/test/jsx-runtime.spec.tsx
@@ -1,5 +1,3 @@
-import 'declarative-shadow-dom-polyfill';
-
import { DOMRenderer } from '../source/dist';
class MyTag extends HTMLElement {}