From 28c1319d4008ca5fce37f66a935840fbfcf7a5e8 Mon Sep 17 00:00:00 2001 From: South Drifted Date: Wed, 20 Nov 2024 20:24:10 +0800 Subject: [PATCH] [add] Async Render mode based on Scheduler API (#11) --- ReadMe.md | 97 ++++++++++++++++++++++++++++++-------- package.json | 7 +-- pnpm-lock.yaml | 28 +++++++---- source/dist/DOMRenderer.ts | 65 ++++++++++++++++++++++--- test/DOMRenderer.spec.ts | 43 ++++++++++++++++- test/jsx-runtime.spec.tsx | 2 - 6 files changed, 198 insertions(+), 44 deletions(-) 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 -[![Edit DOM Renderer example](https://codesandbox.io/static/img/play-codesandbox.svg)][7] +[![Edit DOM Renderer example](https://codesandbox.io/static/img/play-codesandbox.svg)][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 -[![Edit MobX Web components](https://codesandbox.io/static/img/play-codesandbox.svg)][8] +[![Edit MobX Web components](https://codesandbox.io/static/img/play-codesandbox.svg)][18] ## Original ### Inspiration -[![SnabbDOM](https://github.com/snabbdom.png)][9] +[![SnabbDOM](https://github.com/snabbdom.png)][19] ### Prototype -[![Edit DOM Renderer](https://codesandbox.io/static/img/play-codesandbox.svg)][10] +[![Edit DOM Renderer](https://codesandbox.io/static/img/play-codesandbox.svg)][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 {}