Skip to content

Commit 8a5f97e

Browse files
committed
Add better custom element support by tightening overload detection
Tighted the disambiguation for `h(tagName, propsOrChild)`, to see `props` as `child` only when it can never be properties. For realworld hast use cases, this pratically adds support for properties with `type` (`string`) *and* `value` on custom elements. And removes support for literal nodes (those with a `value`) from being passed by omitting properties. You likely don’t need to pass literals as the second argument. As the standard hast `text` literal can always be passed as just the string. Or wrap it in an array. Or pass empty props. Closes GH-21.
1 parent 5063431 commit 8a5f97e

File tree

5 files changed

+103
-120
lines changed

5 files changed

+103
-120
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ coverage/
44
node_modules/
55
test/jsx-*.js
66
yarn.lock
7+
*.d.ts.map
78
*.d.ts
89
!lib/jsx-classic.d.ts
910
!lib/jsx-automatic.d.ts

lib/create-h.js

+37-25
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@ import {parseSelector} from 'hast-util-parse-selector'
4040
import {find, normalize} from 'property-information'
4141
import {parse as spaces} from 'space-separated-tokens'
4242

43-
const buttonTypes = new Set(['button', 'menu', 'reset', 'submit'])
44-
4543
const own = {}.hasOwnProperty
4644

4745
/**
@@ -104,7 +102,9 @@ export function createH(schema, defaultTagName, caseSensitive) {
104102
}
105103

106104
// Handle props.
107-
if (isProperties(properties, node.tagName)) {
105+
if (isChild(properties)) {
106+
children.unshift(properties)
107+
} else {
108108
/** @type {string} */
109109
let key
110110

@@ -113,8 +113,6 @@ export function createH(schema, defaultTagName, caseSensitive) {
113113
addProperty(schema, node.properties, key, properties[key])
114114
}
115115
}
116-
} else {
117-
children.unshift(properties)
118116
}
119117
}
120118

@@ -139,34 +137,48 @@ export function createH(schema, defaultTagName, caseSensitive) {
139137
*
140138
* @param {Child | Properties} value
141139
* Value to check.
142-
* @param {string} name
143-
* Tag name.
144-
* @returns {value is Properties}
145-
* Whether `value` is a properties object.
140+
* @returns {value is Child}
141+
* Whether `value` is definitely a child.
146142
*/
147-
function isProperties(value, name) {
148-
if (
149-
value === null ||
150-
value === undefined ||
151-
typeof value !== 'object' ||
152-
Array.isArray(value)
153-
) {
154-
return false
155-
}
156-
157-
if (name === 'input' || !value.type || typeof value.type !== 'string') {
143+
function isChild(value) {
144+
// Never properties if not an object.
145+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
158146
return true
159147
}
160148

161-
if ('children' in value && Array.isArray(value.children)) {
162-
return false
149+
// Never node without `type`; that’s the main discriminator.
150+
if (typeof value.type !== 'string') return false
151+
152+
// Slower check: never property value if object or array with
153+
// non-number/strings.
154+
const record = /** @type {Record<string, unknown>} */ (value)
155+
const keys = Object.keys(value)
156+
157+
for (const key of keys) {
158+
const value = record[key]
159+
160+
if (value && typeof value === 'object') {
161+
if (!Array.isArray(value)) return true
162+
163+
const list = /** @type {Array<unknown>} */ (value)
164+
165+
for (const item of list) {
166+
if (typeof item !== 'number' && typeof item !== 'string') {
167+
return true
168+
}
169+
}
170+
}
163171
}
164172

165-
if (name === 'button') {
166-
return buttonTypes.has(value.type.toLowerCase())
173+
// Also see empty `children` as a node.
174+
if ('children' in value && Array.isArray(value.children)) {
175+
return true
167176
}
168177

169-
return !('value' in value)
178+
// Default to properties, someone can always pass an empty object,
179+
// put `data: {}` in a node,
180+
// or wrap it in an array.
181+
return false
170182
}
171183

172184
/**

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
},
4141
"files": [
4242
"lib/",
43+
"index.d.ts.map",
4344
"index.d.ts",
4445
"index.js"
4546
],

test/core.js

+63-95
Original file line numberDiff line numberDiff line change
@@ -742,132 +742,100 @@ test('children', async function (t) {
742742
}
743743
)
744744

745-
await t.test(
746-
'should allow omitting `properties` for a `string`',
747-
async function () {
748-
assert.deepEqual(h('strong', 'foo'), {
749-
type: 'element',
750-
tagName: 'strong',
751-
properties: {},
752-
children: [{type: 'text', value: 'foo'}]
753-
})
754-
}
755-
)
756-
757-
await t.test(
758-
'should allow omitting `properties` for a node',
759-
async function () {
760-
assert.deepEqual(h('strong', h('span', 'foo')), {
761-
type: 'element',
762-
tagName: 'strong',
763-
properties: {},
764-
children: [
765-
{
766-
type: 'element',
767-
tagName: 'span',
768-
properties: {},
769-
children: [{type: 'text', value: 'foo'}]
770-
}
771-
]
772-
})
773-
}
774-
)
775-
776-
await t.test(
777-
'should allow omitting `properties` for an array',
778-
async function () {
779-
assert.deepEqual(h('strong', ['foo', 'bar']), {
780-
type: 'element',
781-
tagName: 'strong',
782-
properties: {},
783-
children: [
784-
{type: 'text', value: 'foo'},
785-
{type: 'text', value: 'bar'}
786-
]
787-
})
788-
}
789-
)
790-
791-
await t.test(
792-
'should *not* allow omitting `properties` for an `input[type=text][value]`, as those are void and clash',
793-
async function () {
794-
assert.deepEqual(h('input', {type: 'text', value: 'foo'}), {
795-
type: 'element',
796-
tagName: 'input',
797-
properties: {type: 'text', value: 'foo'},
798-
children: []
799-
})
800-
}
801-
)
745+
await t.test('should disambiguate non-object as a child', async function () {
746+
assert.deepEqual(h('x', 'y'), {
747+
type: 'element',
748+
tagName: 'x',
749+
properties: {},
750+
children: [{type: 'text', value: 'y'}]
751+
})
752+
})
802753

803-
await t.test(
804-
'should *not* allow omitting `properties` for a `[type]`, without `value` or `children`',
805-
async function () {
806-
assert.deepEqual(h('a', {type: 'text/html'}), {
807-
type: 'element',
808-
tagName: 'a',
809-
properties: {type: 'text/html'},
810-
children: []
811-
})
812-
}
813-
)
754+
await t.test('should disambiguate `array` as a child', async function () {
755+
assert.deepEqual(h('x', ['y']), {
756+
type: 'element',
757+
tagName: 'x',
758+
properties: {},
759+
children: [{type: 'text', value: 'y'}]
760+
})
761+
})
814762

815763
await t.test(
816-
'should *not* allow omitting `properties` when `children` is not set to an array',
764+
'should not disambiguate an object w/o `type` as a child',
817765
async function () {
818-
assert.deepEqual(h('foo', {type: 'text/html', children: {bar: 'baz'}}), {
819-
type: 'element',
820-
tagName: 'foo',
821-
properties: {type: 'text/html', children: '[object Object]'},
822-
children: []
823-
})
766+
assert.deepEqual(
767+
// @ts-expect-error: incorrect properties.
768+
h('x', {
769+
a: 'y',
770+
b: 1,
771+
c: true,
772+
d: ['z'],
773+
e: {f: true}
774+
}),
775+
{
776+
type: 'element',
777+
tagName: 'x',
778+
properties: {
779+
a: 'y',
780+
b: 1,
781+
c: true,
782+
d: ['z'],
783+
e: '[object Object]'
784+
},
785+
children: []
786+
}
787+
)
824788
}
825789
)
826790

827791
await t.test(
828-
'should *not* allow omitting `properties` when a button has a valid type',
792+
'should disambiguate an object w/ a `type` and an array of non-primitives as a child',
829793
async function () {
830-
assert.deepEqual(h('button', {type: 'submit', value: 'Send'}), {
831-
type: 'element',
832-
tagName: 'button',
833-
properties: {type: 'submit', value: 'Send'},
834-
children: []
835-
})
794+
assert.deepEqual(
795+
// @ts-expect-error: unknown node.
796+
h('x', {type: 'y', key: [{value: 1}]}),
797+
{
798+
type: 'element',
799+
tagName: 'x',
800+
properties: {},
801+
children: [{type: 'y', key: [{value: 1}]}]
802+
}
803+
)
836804
}
837805
)
838806

839807
await t.test(
840-
'should *not* allow omitting `properties` when a button has a valid non-lowercase type',
808+
'should not disambiguate an object w/ a `type` and an array of primitives as a child',
841809
async function () {
842-
assert.deepEqual(h('button', {type: 'BUTTON', value: 'Send'}), {
810+
assert.deepEqual(h('x', {type: 'y', key: [1]}), {
843811
type: 'element',
844-
tagName: 'button',
845-
properties: {type: 'BUTTON', value: 'Send'},
812+
tagName: 'x',
813+
properties: {type: 'y', key: [1]},
846814
children: []
847815
})
848816
}
849817
)
850818

851819
await t.test(
852-
'should *not* allow omitting `properties` when a button has a valid type',
820+
'should disambiguate an object w/ a `type` and an `object` as a child',
853821
async function () {
854-
assert.deepEqual(h('button', {type: 'menu', value: 'Send'}), {
822+
assert.deepEqual(h('x', {type: 'y', data: {bar: 'baz'}}), {
855823
type: 'element',
856-
tagName: 'button',
857-
properties: {type: 'menu', value: 'Send'},
858-
children: []
824+
tagName: 'x',
825+
properties: {},
826+
children: [{type: 'y', data: {bar: 'baz'}}]
859827
})
860828
}
861829
)
862830

863831
await t.test(
864-
'should allow omitting `properties` when a button has an invalid type',
832+
'should disambiguate an object w/ a `type` and an empty `children` array is a child',
865833
async function () {
866-
assert.deepEqual(h('button', {type: 'text', value: 'Send'}), {
834+
assert.deepEqual(h('x', {type: 'y', children: []}), {
867835
type: 'element',
868-
tagName: 'button',
836+
tagName: 'x',
869837
properties: {},
870-
children: [{type: 'text', value: 'Send'}]
838+
children: [{type: 'y', children: []}]
871839
})
872840
}
873841
)

tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"checkJs": true,
44
"customConditions": ["development"],
55
"declaration": true,
6+
"declarationMap": true,
67
"emitDeclarationOnly": true,
78
"exactOptionalPropertyTypes": true,
89
"jsx": "preserve",

0 commit comments

Comments
 (0)