From ffc7c513c1ac756102708d5a70f91ff0fc985d69 Mon Sep 17 00:00:00 2001 From: Ryota Kameoka Date: Tue, 6 Jul 2021 14:35:55 +0900 Subject: [PATCH] feat: support JSX fragments ISSUES CLOSED: #560 --- README.md | 31 ++++- src/index.ts | 8 +- src/jsx.ts | 26 +++- test/tsconfig.json | 3 +- test/unit/jsx.tsx | 326 ++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 384 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6bc2a9f..191243b 100644 --- a/README.md +++ b/README.md @@ -780,6 +780,9 @@ significant computational time to generate. ## JSX +Note that JSX fragments are still experimental and must be opted in. +See [`fragment`](#fragment-experimental) section for details. + ### TypeScript Add the following options to your `tsconfig.json`: @@ -788,21 +791,29 @@ Add the following options to your `tsconfig.json`: { "compilerOptions": { "jsx": "react", - "jsxFactory": "jsx" + "jsxFactory": "jsx", + "jsxFragmentFactory": "Fragment" } } ``` -Then make sure that you use the `.tsx` file extension and import the `jsx` function at the top of the file: +Then make sure that you use the `.tsx` file extension and import the `jsx` function and the `Fragment` function at the top of the file: ```tsx -import { jsx, VNode } from "snabbdom"; +import { Fragment, jsx, VNode } from "snabbdom"; const node: VNode = (
I was created with JSX
); + +const fragment: VNode = ( + <> + JSX fragments + are experimentally supported + +); ``` ### Babel @@ -815,23 +826,31 @@ Add the following options to your babel configuration: [ "@babel/plugin-transform-react-jsx", { - "pragma": "jsx" + "pragma": "jsx", + "pragmaFrag": "Fragment" } ] ] } ``` -Then import the `jsx` function at the top of the file: +Then import the `jsx` function and the `Fragment` function at the top of the file: ```jsx -import { jsx } from "snabbdom"; +import { Fragment, jsx } from "snabbdom"; const node = (
I was created with JSX
); + +const fragment = ( + <> + JSX fragments + are experimentally supported + +); ``` ## Virtual Node diff --git a/src/index.ts b/src/index.ts index 1b5737c..a184d71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,4 +30,10 @@ export { Props, propsModule } from "./modules/props"; export { VNodeStyle, styleModule } from "./modules/style"; // JSX -export { JsxVNodeChild, JsxVNodeChildren, FunctionComponent, jsx } from "./jsx"; +export { + JsxVNodeChild, + JsxVNodeChildren, + FunctionComponent, + jsx, + Fragment, +} from "./jsx"; diff --git a/src/jsx.ts b/src/jsx.ts index 0efc6f3..46795ee 100644 --- a/src/jsx.ts +++ b/src/jsx.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-namespace, import/export */ -import { vnode, VNode, VNodeData } from "./vnode"; +import { Key, vnode, VNode, VNodeData } from "./vnode"; import { h, ArrayOrElement } from "./h"; // See https://www.typescriptlang.org/docs/handbook/jsx.html#type-checking @@ -25,6 +25,30 @@ export type FunctionComponent = ( children?: VNode[] ) => VNode; +export function Fragment( + data: { key?: Key } | null, + ...children: JsxVNodeChildren[] +): VNode { + const flatChildren = flattenAndFilter(children, []); + + if ( + flatChildren.length === 1 && + !flatChildren[0].sel && + flatChildren[0].text + ) { + // only child is a simple text node, pass as text for a simpler vtree + return vnode( + undefined, + undefined, + undefined, + flatChildren[0].text, + undefined + ); + } else { + return vnode(undefined, data ?? {}, flatChildren, undefined, undefined); + } +} + function flattenAndFilter( children: JsxVNodeChildren[], flattened: VNode[] diff --git a/test/tsconfig.json b/test/tsconfig.json index a698ec2..6256d6c 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "esModuleInterop": true, "jsx": "react", - "jsxFactory": "jsx" + "jsxFactory": "jsx", + "jsxFragmentFactory": "Fragment" }, "include": ["./**/*.ts", "./unit/*.tsx", "../src/index.ts"] } diff --git a/test/unit/jsx.tsx b/test/unit/jsx.tsx index 392a5eb..f405acd 100644 --- a/test/unit/jsx.tsx +++ b/test/unit/jsx.tsx @@ -1,5 +1,5 @@ import { assert } from "chai"; -import { jsx } from "../../src/index"; +import { jsx, Fragment, init } from "../../src/index"; describe("snabbdom", function () { describe("jsx", function () { @@ -272,4 +272,328 @@ describe("snabbdom", function () { }); }); }); + + describe("Fragment", function () { + it("can be used as a jsxFragmentFactory method", function () { + const vnode = <>Hello World; + + assert.deepStrictEqual(vnode, { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "Hello World", + key: undefined, + }); + }); + + it("creates text property for text only child", function () { + const vnode = <>foo bar; + + assert.deepStrictEqual(vnode, { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "foo bar", + key: undefined, + }); + }); + + it("creates an array of children for multiple children", function () { + const vnode = ( + <> + {"foo"} + {"bar"} + + ); + + assert.deepStrictEqual(vnode, { + sel: undefined, + data: {}, + children: [ + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "foo", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "bar", + key: undefined, + }, + ], + elm: undefined, + text: undefined, + key: undefined, + }); + }); + + it("flattens children", function () { + const vnode = ( + <> +

A Heading

+ some description + {["part1", "part2"].map((part) => ( + {part} + ))} + + ); + + assert.deepStrictEqual(vnode, { + sel: undefined, + data: {}, + children: [ + { + sel: "h1", + data: {}, + children: undefined, + elm: undefined, + text: "A Heading", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "some description", + key: undefined, + }, + { + sel: "span", + data: {}, + children: undefined, + elm: undefined, + text: "part1", + key: undefined, + }, + { + sel: "span", + data: {}, + children: undefined, + elm: undefined, + text: "part2", + key: undefined, + }, + ], + elm: undefined, + text: undefined, + key: undefined, + }); + }); + + it("removes falsey children", function () { + const showLogin = false; + const showCaptcha = false; + const loginAttempts = 0; + const userName = ""; + const profilePic = undefined; + const isLoggedIn = true; + const vnode = ( + <> + Login Form + {showLogin && } + {showCaptcha ? : null} + {userName} + {profilePic} + Login Attempts: {loginAttempts} + Logged In: {isLoggedIn} + + ); + + assert.deepStrictEqual(vnode, { + sel: undefined, + data: {}, + children: [ + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "Login Form", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "Login Attempts: ", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "0", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "Logged In: ", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "true", + key: undefined, + }, + ], + elm: undefined, + text: undefined, + key: undefined, + }); + }); + + it("works with a function component", function () { + // workaround linter issue + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const Part = ({ part }: { part: string }) => <>{part}; + const vnode = ( +
+ + Snabbdom + + and tsx + {["work", "like", "a", "charm!"].map((part) => ( + + ))} + {"πŸ’ƒπŸ•ΊπŸŽ‰"} +
+ ); + + assert.deepStrictEqual(vnode, { + sel: "div", + data: {}, + children: [ + { + sel: "a", + data: { attrs: { href: "https://github.com/snabbdom/snabbdom" } }, + children: undefined, + elm: undefined, + text: "Snabbdom", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "and tsx", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "work", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "like", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "a", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "charm!", + key: undefined, + }, + { + sel: undefined, + data: undefined, + children: undefined, + elm: undefined, + text: "πŸ’ƒπŸ•ΊπŸŽ‰", + key: undefined, + }, + ], + elm: undefined, + text: undefined, + key: undefined, + }); + }); + + it("can correctly be patched", function () { + const patch = init([], undefined, { + experimental: { + fragments: true, + }, + }); + const container = document.createElement("div"); + + const vnode0 = ( + <> + Nested + <> + children + <> will be flattened + + + ); + + patch(container, vnode0); + assert.strictEqual(vnode0.elm?.nodeType, document.DOCUMENT_FRAGMENT_NODE); + assert.strictEqual( + vnode0.elm?.textContent, + "Nested children will be flattened" + ); + + const vnode1 = ( +
+ Nested + <> + child nodes + {[" will", " be", " patched"]} + +
+ ); + + patch(vnode0, vnode1); + assert.strictEqual((vnode1.elm as HTMLElement).tagName, "DIV"); + assert.strictEqual( + vnode1.elm?.textContent, + "Nested child nodes will be patched" + ); + + const vnode2 = ( + + And <>fragment again! + + ); + + patch(vnode1, vnode2); + assert.strictEqual(vnode2.elm?.nodeType, document.DOCUMENT_FRAGMENT_NODE); + assert.strictEqual(vnode2.elm?.textContent, "And fragment again!"); + }); + }); });