Skip to content

Commit 520517b

Browse files
feat: add DOM createElement adapter with Fragment support and browser e2e coverage.
1 parent fe3c9f5 commit 520517b

8 files changed

Lines changed: 293 additions & 24 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/jsx",
3-
"version": "1.8.0",
3+
"version": "1.9.0",
44
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
55
"keywords": [
66
"jsx runtime",
@@ -136,6 +136,7 @@
136136
"test:watch": "cross-env KNIGHTED_JSX_CLI_TEST=1 vitest",
137137
"test:e2e": "npm run build && npm run setup:wasm && npm run build:fixture && playwright test",
138138
"build:fixture": "node scripts/build-rspack-fixture.mjs",
139+
"demo:e2e-fixture": "npm run build && npx serve . -l tcp://127.0.0.1:4173",
139140
"demo:node-ssr": "node test/fixtures/node-ssr/render.mjs",
140141
"dev": "vite dev --config vite.config.ts",
141142
"build:demo": "vite build --config vite.config.ts",

playwright/esm-demo.spec.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ const hybridSelectors = {
66
litHost: 'lit-hosts-react',
77
litReactBadge: 'lit-hosts-react .hybrid-react-badge',
88
liteMetrics: '#lite-entrypaths .lite-metric',
9+
createElementCard: '.create-element-card',
10+
createElementCounter: '.create-element-value',
11+
createElementNodeType: '.create-element-node-type',
12+
createElementButton: '.create-element-button',
13+
createElementFragmentItems: '.create-element-fragment-item',
914
}
1015

1116
const overviewSelectors = {
@@ -20,7 +25,7 @@ test.describe('esm demo via local dist fixture', () => {
2025
test('renders nested DOM trees and increments counter', async ({ page }) => {
2126
await page.goto('/test/fixtures/e2e.html')
2227

23-
const counterButton = page.locator('.counter-button')
28+
const counterButton = page.getByRole('button', { name: 'Increment', exact: true })
2429
const counterLabel = page.locator('.counter-value')
2530

2631
await expect(counterLabel).toHaveText('0')
@@ -56,4 +61,20 @@ test.describe('esm demo via local dist fixture', () => {
5661
).toHaveText(/Connected|Paused/)
5762
await expect(page.locator(hybridSelectors.liteMetrics)).toHaveCount(4)
5863
})
64+
65+
test('renders createElement + Fragment output as real DOM nodes', async ({ page }) => {
66+
await page.goto('/test/fixtures/e2e.html')
67+
68+
const createElementCard = page.locator(hybridSelectors.createElementCard)
69+
const createElementCounter = page.locator(hybridSelectors.createElementCounter)
70+
const createElementButton = page.locator(hybridSelectors.createElementButton)
71+
72+
await expect(createElementCard).toBeVisible()
73+
await expect(page.locator(hybridSelectors.createElementNodeType)).toHaveText('1')
74+
await expect(page.locator(hybridSelectors.createElementFragmentItems)).toHaveCount(2)
75+
76+
await expect(createElementCounter).toHaveText('0')
77+
await createElementButton.click()
78+
await expect(createElementCounter).toHaveText('1')
79+
})
5980
})

src/internal/dom-create-element.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { Namespace } from './attribute-resolution.js'
2+
import type { JsxComponent, JsxRenderable } from './jsx-types.js'
3+
4+
export const Fragment = Symbol.for('@knighted/jsx::Fragment')
5+
6+
type JsxPropsRecord = Record<string, unknown>
7+
8+
export type JsxCreateElement = {
9+
(type: typeof Fragment, props: null, ...children: JsxRenderable[]): DocumentFragment
10+
<Props extends JsxPropsRecord>(
11+
type: JsxComponent<Props>,
12+
props: (Props & { children?: JsxRenderable | JsxRenderable[] }) | null,
13+
...children: JsxRenderable[]
14+
): JsxRenderable
15+
(
16+
type: string,
17+
props: JsxPropsRecord | null,
18+
...children: JsxRenderable[]
19+
): JsxRenderable
20+
}
21+
22+
type DomCreateElementHelpers = {
23+
ensureDomAvailable: () => void
24+
appendChildValue: (parent: Node & ParentNode, value: JsxRenderable) => void
25+
setDomProp: (
26+
element: Element,
27+
name: string,
28+
value: unknown,
29+
namespace: Namespace,
30+
) => void
31+
isPromiseLike: (value: unknown) => value is PromiseLike<unknown>
32+
}
33+
34+
const resolveChildrenForCreateElement = (
35+
props: JsxPropsRecord,
36+
children: JsxRenderable[],
37+
) => {
38+
if (children.length > 0) {
39+
return children
40+
}
41+
42+
if (!Object.prototype.hasOwnProperty.call(props, 'children')) {
43+
return []
44+
}
45+
46+
return [props.children as JsxRenderable]
47+
}
48+
49+
const createPropsForComponent = (props: JsxPropsRecord, children: JsxRenderable[]) => {
50+
const nextProps = { ...props }
51+
52+
if (children.length === 1) {
53+
nextProps.children = children[0]
54+
} else if (children.length > 1) {
55+
nextProps.children = children
56+
} else {
57+
delete nextProps.children
58+
}
59+
60+
return nextProps
61+
}
62+
63+
export const createDomCreateElement = ({
64+
ensureDomAvailable,
65+
appendChildValue,
66+
setDomProp,
67+
isPromiseLike,
68+
}: DomCreateElementHelpers): JsxCreateElement => {
69+
function createElement(
70+
type: typeof Fragment,
71+
props: null,
72+
...children: JsxRenderable[]
73+
): DocumentFragment
74+
function createElement<Props extends JsxPropsRecord>(
75+
type: JsxComponent<Props>,
76+
props: (Props & { children?: JsxRenderable | JsxRenderable[] }) | null,
77+
...children: JsxRenderable[]
78+
): JsxRenderable
79+
function createElement(
80+
type: string,
81+
props: JsxPropsRecord | null,
82+
...children: JsxRenderable[]
83+
): JsxRenderable
84+
function createElement(
85+
type: string | JsxComponent | typeof Fragment,
86+
props: JsxPropsRecord | null,
87+
...children: JsxRenderable[]
88+
): JsxRenderable {
89+
ensureDomAvailable()
90+
91+
const nextProps = props ? { ...props } : {}
92+
const resolvedChildren = resolveChildrenForCreateElement(nextProps, children)
93+
94+
if (type === Fragment) {
95+
const fragment = document.createDocumentFragment()
96+
resolvedChildren.forEach(child => appendChildValue(fragment, child))
97+
return fragment
98+
}
99+
100+
if (typeof type === 'function') {
101+
const result = type(createPropsForComponent(nextProps, resolvedChildren))
102+
103+
if (isPromiseLike(result)) {
104+
throw new Error('Async jsx components are not supported.')
105+
}
106+
107+
return result
108+
}
109+
110+
if (typeof type !== 'string') {
111+
throw new Error(`Unsupported jsx createElement type: ${String(type)}`)
112+
}
113+
114+
delete nextProps.children
115+
116+
const nextNamespace: Namespace = type === 'svg' ? 'svg' : null
117+
const domElement =
118+
nextNamespace === 'svg'
119+
? document.createElementNS('http://www.w3.org/2000/svg', type)
120+
: document.createElement(type)
121+
122+
Object.entries(nextProps).forEach(([name, value]) => {
123+
if (name === 'key') {
124+
return
125+
}
126+
127+
setDomProp(domElement, name, value, nextNamespace)
128+
})
129+
130+
resolvedChildren.forEach(value => appendChildValue(domElement, value))
131+
132+
return domElement
133+
}
134+
135+
return createElement
136+
}

src/internal/jsx-types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type JsxRenderable =
2+
| Node
3+
| DocumentFragment
4+
| string
5+
| number
6+
| bigint
7+
| boolean
8+
| null
9+
| undefined
10+
| Iterable<JsxRenderable>
11+
12+
export type JsxComponent<Props = Record<string, unknown>> = {
13+
(props: Props & { children?: JsxRenderable | JsxRenderable[] }): JsxRenderable
14+
displayName?: string
15+
}

src/jsx.ts

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,16 @@ import {
3232
parseEventPropName,
3333
resolveEventHandlerValue,
3434
} from './internal/event-bindings.js'
35+
import {
36+
Fragment,
37+
createDomCreateElement,
38+
type JsxCreateElement,
39+
} from './internal/dom-create-element.js'
40+
import type { JsxComponent, JsxRenderable } from './internal/jsx-types.js'
3541

3642
type JsxContext = TemplateContext<JsxComponent>
3743
type ElementWithIndex = Element & Record<string, unknown>
38-
39-
export type JsxRenderable =
40-
| Node
41-
| DocumentFragment
42-
| string
43-
| number
44-
| bigint
45-
| boolean
46-
| null
47-
| undefined
48-
| Iterable<JsxRenderable>
49-
50-
export type JsxComponent<Props = Record<string, unknown>> = {
51-
(props: Props & { children?: JsxRenderable | JsxRenderable[] }): JsxRenderable
52-
displayName?: string
53-
}
44+
export type { JsxRenderable, JsxComponent } from './internal/jsx-types.js'
5445

5546
const ensureDomAvailable = () => {
5647
if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
@@ -477,10 +468,20 @@ const evaluateJsxNode = (
477468
return evaluateJsxElement(node, ctx, namespace)
478469
}
479470

480-
export const jsx = (
481-
templates: TemplateStringsArray,
482-
...values: unknown[]
483-
): JsxRenderable => {
471+
export const createElement: JsxCreateElement = createDomCreateElement({
472+
ensureDomAvailable,
473+
appendChildValue,
474+
setDomProp,
475+
isPromiseLike,
476+
})
477+
478+
type JsxTaggedTemplate = {
479+
(templates: TemplateStringsArray, ...values: unknown[]): JsxRenderable
480+
createElement: typeof createElement
481+
Fragment: typeof Fragment
482+
}
483+
484+
const jsxTag = (templates: TemplateStringsArray, ...values: unknown[]): JsxRenderable => {
484485
ensureDomAvailable()
485486
const build = buildTemplate<JsxComponent>(templates, values)
486487
const result = parseSync('inline.jsx', build.source, parserOptions)
@@ -505,3 +506,8 @@ export const jsx = (
505506

506507
return evaluateJsxNode(root, ctx, null)
507508
}
509+
510+
export const jsx = Object.assign(jsxTag, {
511+
createElement,
512+
Fragment,
513+
}) as JsxTaggedTemplate

test/fixtures/e2e.html

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,67 @@ <h2>Stateful counter rendered with <code>jsx</code></h2>
768768
</section>
769769
`
770770
}
771+
const CreateElementCounter = ({ start = 0 }) => {
772+
let value = start
773+
const valueLabel = jsx.createElement(
774+
'strong',
775+
{ class: 'create-element-value' },
776+
String(value),
777+
)
778+
const nodeTypeLabel = jsx.createElement(
779+
'code',
780+
{ class: 'create-element-node-type' },
781+
String(valueLabel.nodeType),
782+
)
783+
const increment = () => {
784+
value += 1
785+
valueLabel.textContent = String(value)
786+
}
787+
788+
return jsx.createElement(
789+
'section',
790+
{ class: 'card dom-card create-element-card' },
791+
jsx.createElement(
792+
'header',
793+
null,
794+
jsx.createElement('p', { class: 'eyebrow' }, 'createElement facade'),
795+
jsx.createElement('h2', null, 'DOM parity with jsx.createElement + Fragment'),
796+
),
797+
jsx.createElement(
798+
'p',
799+
null,
800+
'This section is rendered without template literals to validate direct createElement usage.',
801+
),
802+
jsx.createElement('p', null, 'Current total: ', valueLabel),
803+
jsx.createElement('p', null, 'valueLabel.nodeType = ', nodeTypeLabel),
804+
jsx.createElement(
805+
'ul',
806+
{ class: 'list create-element-fragment-list' },
807+
jsx.createElement(
808+
jsx.Fragment,
809+
null,
810+
jsx.createElement(
811+
'li',
812+
{ class: 'create-element-fragment-item' },
813+
'Fragment child one',
814+
),
815+
jsx.createElement(
816+
'li',
817+
{ class: 'create-element-fragment-item' },
818+
'Fragment child two',
819+
),
820+
),
821+
),
822+
jsx.createElement(
823+
'button',
824+
{
825+
class: 'counter-button create-element-button',
826+
onClick: increment,
827+
},
828+
'Increment via createElement',
829+
),
830+
)
831+
}
771832
const reactMetrics = ['Hooks ready', 'Fragments inline', 'Suspense friendly']
772833
const ReactBadge = ({ label }) =>
773834
reactJsx`
@@ -1143,6 +1204,7 @@ <h2>All the ways to use @knighted/jsx</h2>
11431204
</p>
11441205
</section>
11451206
<${Counter} start={${0}} />
1207+
<${CreateElementCounter} start={${0}} />
11461208
<section class="card react-card">
11471209
<header>
11481210
<h2>Try the React runtime</h2>

0 commit comments

Comments
 (0)