diff --git a/deno_dist/jsx/base.ts b/deno_dist/jsx/base.ts index fdc8dc0e5..01afff680 100644 --- a/deno_dist/jsx/base.ts +++ b/deno_dist/jsx/base.ts @@ -4,11 +4,16 @@ import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html import type { Context } from './context.ts' import { globalContexts } from './context.ts' import type { IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements.ts' -import { normalizeIntrinsicElementProps } from './utils.ts' +import { normalizeIntrinsicElementProps, styleObjectForEach } from './utils.ts' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Props = Record -export type FC = (props: T) => HtmlEscapedString | Promise +export type FC

= { + (props: P): HtmlEscapedString | Promise + defaultProps?: Partial

| undefined + displayName?: string | undefined +} +export type DOMAttributes = Hono.HTMLAttributes declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -114,6 +119,16 @@ export class JSXNode implements HtmlEscaped { this.children = children } + get type(): string | Function { + return this.tag as string + } + + // Added for compatibility with libraries that rely on React's internal structure + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get ref(): any { + return this.props.ref || null + } + toString(): string | Promise { const buffer: StringBuffer = [''] this.localContexts?.forEach(([context, value]) => { @@ -141,16 +156,18 @@ export class JSXNode implements HtmlEscaped { for (let i = 0, len = propsKeys.length; i < len; i++) { const key = propsKeys[i] const v = props[key] - // object to style strings - if (key === 'style' && typeof v === 'object') { - const styles = Object.keys(v) - .map((k) => { - const property = k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) - return `${property}:${v[k]}` - }) - .join(';') + if (key === 'children') { + // skip children + } else if (key === 'style' && typeof v === 'object') { + // object to style strings + let styleStr = '' + styleObjectForEach(v, (property, value) => { + if (value != null) { + styleStr += `${styleStr ? ';' : ''}${property}:${value}` + } + }) buffer[0] += ' style="' - escapeToBuffer(styles, buffer) + escapeToBuffer(styleStr, buffer) buffer[0] += '"' } else if (typeof v === 'string') { buffer[0] += ` ${key}="` @@ -241,14 +258,17 @@ export class JSXFragmentNode extends JSXNode { export const jsx = ( tag: string | Function, - props: Props, - ...children: (string | HtmlEscapedString)[] + props: Props | null, + ...children: (string | number | HtmlEscapedString)[] ): JSXNode => { - let key - if (props) { - key = props?.key - delete props['key'] + props ??= {} + if (children.length) { + props.children = children.length === 1 ? children[0] : children } + + const key = props.key + delete props['key'] + const node = jsxFn(tag, props, children) node.key = key return node @@ -257,7 +277,7 @@ export const jsx = ( export const jsxFn = ( tag: string | Function, props: Props, - children: (string | HtmlEscapedString)[] + children: (string | number | HtmlEscapedString)[] ): JSXNode => { if (typeof tag === 'function') { return new JSXFunctionNode(tag, props, children) @@ -317,19 +337,15 @@ export const Fragment = ({ }): HtmlEscapedString => { return new JSXFragmentNode( '', - {}, + { + children, + }, Array.isArray(children) ? children : children ? [children] : [] ) as never } export const isValidElement = (element: unknown): element is JSXNode => { - return !!( - element && - typeof element === 'object' && - 'tag' in element && - 'props' in element && - 'children' in element - ) + return !!(element && typeof element === 'object' && 'tag' in element && 'props' in element) } export const cloneElement = ( @@ -337,10 +353,9 @@ export const cloneElement = ( props: Partial, ...children: Child[] ): T => { - return jsxFn( + return jsx( (element as JSXNode).tag, { ...(element as JSXNode).props, ...props }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - children.length ? children : ((element as JSXNode).children as any) || [] + ...(children as (string | number | HtmlEscapedString)[]) ) as T } diff --git a/deno_dist/jsx/children.ts b/deno_dist/jsx/children.ts new file mode 100644 index 000000000..377bdeb3c --- /dev/null +++ b/deno_dist/jsx/children.ts @@ -0,0 +1,20 @@ +import type { Child } from './base.ts' + +export const toArray = (children: Child): Child[] => + Array.isArray(children) ? children : [children] +export const Children = { + map: (children: Child[], fn: (child: Child, index: number) => Child): Child[] => + toArray(children).map(fn), + forEach: (children: Child[], fn: (child: Child, index: number) => void): void => { + toArray(children).forEach(fn) + }, + count: (children: Child[]): number => toArray(children).length, + only: (_children: Child[]): Child => { + const children = toArray(_children) + if (children.length !== 1) { + throw new Error('Children.only() expects only one child') + } + return children[0] + }, + toArray, +} diff --git a/deno_dist/jsx/constants.ts b/deno_dist/jsx/constants.ts index 1762ad046..1c5a37df7 100644 --- a/deno_dist/jsx/constants.ts +++ b/deno_dist/jsx/constants.ts @@ -1,3 +1,4 @@ export const DOM_RENDERER = Symbol('RENDERER') export const DOM_ERROR_HANDLER = Symbol('ERROR_HANDLER') export const DOM_STASH = Symbol('STASH') +export const DOM_INTERNAL_TAG = Symbol('INTERNAL') diff --git a/deno_dist/jsx/dom/context.ts b/deno_dist/jsx/dom/context.ts index c41b38450..52fe061ba 100644 --- a/deno_dist/jsx/dom/context.ts +++ b/deno_dist/jsx/dom/context.ts @@ -3,32 +3,44 @@ import { DOM_ERROR_HANDLER } from '../constants.ts' import type { Context } from '../context.ts' import { globalContexts } from '../context.ts' import { Fragment } from './jsx-runtime.ts' +import { setInternalTagFlag } from './utils.ts' -export const createContextProviderFunction = - (values: T[]) => - ({ value, children }: { value: T; children: Child[] }) => { - const res = Fragment({ +export const createContextProviderFunction = (values: T[]) => + setInternalTagFlag(({ value, children }: { value: T; children: Child[] }) => { + if (!children) { + return undefined + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props: { children: any } = { children: [ { - tag: () => { + tag: setInternalTagFlag(() => { values.push(value) - }, - }, - ...(children as Child[]), - { - tag: () => { - values.pop() - }, + }), + props: {}, }, ], + } + if (Array.isArray(children)) { + props.children.push(...children.flat()) + } else { + props.children.push(children) + } + props.children.push({ + tag: setInternalTagFlag(() => { + values.pop() + }), + props: {}, }) + const res = Fragment(props) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(res as any)[DOM_ERROR_HANDLER] = (err: unknown) => { values.pop() throw err } return res - } + }) export const createContext = (defaultValue: T): Context => { const values = [defaultValue] diff --git a/deno_dist/jsx/dom/css.ts b/deno_dist/jsx/dom/css.ts index 108589ce2..913fb0dfc 100644 --- a/deno_dist/jsx/dom/css.ts +++ b/deno_dist/jsx/dom/css.ts @@ -110,10 +110,14 @@ export const createCssJsxDomObjects = ({ id }: { id: Readonly }) => { const Style: FC> = ({ children }) => ({ tag: 'style', - children: (Array.isArray(children) ? children : [children]).map( - (c) => (c as unknown as CssClassName)[STYLE_STRING] - ), - props: { id }, + props: { + id, + children: + children && + (Array.isArray(children) ? children : [children]).map( + (c) => (c as unknown as CssClassName)[STYLE_STRING] + ), + }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) diff --git a/deno_dist/jsx/dom/index.ts b/deno_dist/jsx/dom/index.ts index 9bde6b33d..05a829486 100644 --- a/deno_dist/jsx/dom/index.ts +++ b/deno_dist/jsx/dom/index.ts @@ -1,5 +1,6 @@ -import type { Props, Child, JSXNode } from '../base.ts' +import type { Props, Child, DOMAttributes, JSXNode } from '../base.ts' import { memo, isValidElement } from '../base.ts' +import { Children } from '../children.ts' import { useContext } from '../context.ts' import { useState, @@ -17,19 +18,28 @@ import { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, } from '../hooks/index.ts' import { Suspense, ErrorBoundary } from './components.ts' import { createContext } from './context.ts' -import { jsx } from './jsx-runtime.ts' +import { jsx, Fragment } from './jsx-runtime.ts' +import { flushSync, createPortal } from './render.ts' export { render } from './render.ts' const createElement = ( tag: string | ((props: Props) => JSXNode), - props: Props, + props: Props | null, ...children: Child[] ): JSXNode => { - const jsxProps: Props = { ...props, children } + const jsxProps: Props = props ? { ...props } : {} + if (children.length) { + jsxProps.children = children.length === 1 ? children[0] : children + } + let key = undefined if ('key' in jsxProps) { key = jsxProps.key @@ -49,7 +59,7 @@ const cloneElement = ( { ...(element as JSXNode).props, ...props, - children: children.length ? children : (element as JSXNode).children, + children: children.length ? children : (element as JSXNode).props.children, }, (element as JSXNode).key ) as T @@ -72,6 +82,10 @@ export { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, ErrorBoundary, createContext, @@ -80,6 +94,11 @@ export { isValidElement, createElement, cloneElement, + Children, + Fragment, + DOMAttributes, + flushSync, + createPortal, } export default { @@ -98,6 +117,10 @@ export default { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, ErrorBoundary, createContext, @@ -106,6 +129,14 @@ export default { isValidElement, createElement, cloneElement, + Children, + Fragment, + flushSync, + createPortal, } export type { Context } from '../context.ts' + +// TODO: change to `export type *` after denoify bug is fixed +// https://github.com/garronej/denoify/issues/124 +export * from '../types.ts' diff --git a/deno_dist/jsx/dom/jsx-dev-runtime.ts b/deno_dist/jsx/dom/jsx-dev-runtime.ts index 9ce2d3214..216aca4fc 100644 --- a/deno_dist/jsx/dom/jsx-dev-runtime.ts +++ b/deno_dist/jsx/dom/jsx-dev-runtime.ts @@ -1,23 +1,31 @@ -import type { Props } from '../base.ts' +import type { Props, JSXNode } from '../base.ts' import { normalizeIntrinsicElementProps } from '../utils.ts' -export const jsxDEV = (tag: string | Function, props: Props, key?: string) => { +const JSXNodeCompatPrototype = { + type: { + get(this: { tag: string | Function }): string | Function { + return this.tag + }, + }, + ref: { + get(this: { props?: { ref: unknown } }): unknown { + return this.props?.ref + }, + }, +} + +export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXNode => { if (typeof tag === 'string') { normalizeIntrinsicElementProps(props) } - let children - if (props && 'children' in props) { - children = props.children - delete props['children'] - } else { - children = [] - } - return { - tag, - props, - key, - children: Array.isArray(children) ? children : [children], - } + return Object.defineProperties( + { + tag, + props, + key, + }, + JSXNodeCompatPrototype + ) as JSXNode } export const Fragment = (props: Record) => jsxDEV('', props, undefined) diff --git a/deno_dist/jsx/dom/render.ts b/deno_dist/jsx/dom/render.ts index 22edb92e3..23dfd936b 100644 --- a/deno_dist/jsx/dom/render.ts +++ b/deno_dist/jsx/dom/render.ts @@ -1,12 +1,16 @@ import type { JSXNode } from '../base.ts' import type { FC, Child, Props } from '../base.ts' -import { DOM_RENDERER, DOM_ERROR_HANDLER, DOM_STASH } from '../constants.ts' +import { toArray } from '../children.ts' +import { DOM_RENDERER, DOM_ERROR_HANDLER, DOM_STASH, DOM_INTERNAL_TAG } from '../constants.ts' import type { Context as JSXContext } from '../context.ts' import { globalContexts as globalJSXContexts, useContext } from '../context.ts' import type { EffectData } from '../hooks/index.ts' import { STASH_EFFECT } from '../hooks/index.ts' +import { styleObjectForEach } from '../utils.ts' import { createContext } from './context.ts' // import dom-specific versions +const HONO_PORTAL_ELEMENT = '_hp' + const eventAliasMap: Record = { Change: 'Input', DoubleClick: 'DblClick', @@ -17,6 +21,8 @@ const nameSpaceMap: Record = { math: 'http://www.w3.org/1998/Math/MathML', } as const +const skipProps = new Set(['children']) + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type HasRenderToDom = FC & { [DOM_RENDERER]: FC } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -48,10 +54,10 @@ export type NodeObject = { any[][] ] } & JSXNode -type NodeString = [ - string, // text content - boolean // is dirty -] & { +type NodeString = { + t: string // text content + d: boolean // is dirty +} & { e?: Text // like a NodeObject vC: undefined @@ -87,7 +93,7 @@ export const buildDataStack: [Context, Node][] = [] let nameSpaceContext: JSXContext | undefined = undefined -const isNodeString = (node: Node): node is NodeString => Array.isArray(node) +const isNodeString = (node: Node): node is NodeString => 't' in (node as NodeString) const getEventSpec = (key: string): [string, boolean] | undefined => { const match = key.match(/^on([A-Z][a-zA-Z]+?(?:PointerCapture)?)(Capture)?$/) @@ -101,31 +107,35 @@ const getEventSpec = (key: string): [string, boolean] | undefined => { const applyProps = (container: SupportedElement, attributes: Props, oldAttributes?: Props) => { attributes ||= {} for (const [key, value] of Object.entries(attributes)) { - if (!oldAttributes || oldAttributes[key] !== value) { + if (!skipProps.has(key) && (!oldAttributes || oldAttributes[key] !== value)) { const eventSpec = getEventSpec(key) if (eventSpec) { - if (typeof value !== 'function') { - throw new Error(`Event handler for "${key}" is not a function`) - } - if (oldAttributes) { container.removeEventListener(eventSpec[0], oldAttributes[key], eventSpec[1]) } - container.addEventListener(eventSpec[0], value, eventSpec[1]) + if (value != null) { + if (typeof value !== 'function') { + throw new Error(`Event handler for "${key}" is not a function`) + } + container.addEventListener(eventSpec[0], value, eventSpec[1]) + } } else if (key === 'dangerouslySetInnerHTML' && value) { container.innerHTML = value.__html } else if (key === 'ref') { if (typeof value === 'function') { value(container) - } else if ('current' in value) { + } else if (value && 'current' in value) { value.current = container } } else if (key === 'style') { + const style = container.style if (typeof value === 'string') { - container.style.cssText = value + style.cssText = value } else { - container.style.cssText = '' - Object.assign(container.style, value) + style.cssText = '' + if (value != null) { + styleObjectForEach(value, style.setProperty.bind(style)) + } } } else { const nodeName = container.nodeName @@ -166,7 +176,7 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute } if (oldAttributes) { for (const [key, value] of Object.entries(oldAttributes)) { - if (!(key in attributes)) { + if (!skipProps.has(key) && !(key in attributes)) { const eventSpec = getEventSpec(key) if (eventSpec) { container.removeEventListener(eventSpec[0], value, eventSpec[1]) @@ -197,8 +207,9 @@ const invokeTag = (context: Context, node: NodeObject): Child[] => { try { return [ func.call(null, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...((func as any).defaultProps || {}), ...node.props, - children: node.children, }), ] } finally { @@ -254,24 +265,33 @@ const findInsertBefore = (node: Node | undefined): ChildNode | null => { const removeNode = (node: Node) => { if (!isNodeString(node)) { node[DOM_STASH]?.[1][STASH_EFFECT]?.forEach((data: EffectData) => data[2]?.()) + + if (node.e && node.props?.ref) { + if (typeof node.props.ref === 'function') { + node.props.ref(null) + } else { + node.props.ref.current = null + } + } node.vC?.forEach(removeNode) } - node.e?.remove() - node.tag = undefined + if (node.tag !== HONO_PORTAL_ELEMENT) { + node.e?.remove() + } + if (typeof node.tag === 'function') { + updateMap.delete(node) + fallbackUpdateFnArrayMap.delete(node) + } } const apply = (node: NodeObject, container: Container) => { - if (node.tag === undefined) { - return - } - node.c = container applyNodeObject(node, container) } const applyNode = (node: Node, container: Container) => { if (isNodeString(node)) { - container.textContent = node[0] + container.textContent = node.t } else { applyNodeObject(node, container) } @@ -311,11 +331,11 @@ const applyNodeObject = (node: NodeObject, container: Container) => { let el: SupportedElement | Text if (isNodeString(child)) { - if (child.e && child[1]) { - child.e.textContent = child[0] + if (child.e && child.d) { + child.e.textContent = child.t } - child[1] = false - el = child.e ||= document.createTextNode(child[0]) + child.d = false + el = child.e ||= document.createTextNode(child.t) } else { el = child.e ||= child.n ? (document.createElementNS(child.n, child.tag as string) as SVGElement | MathMLElement) @@ -323,7 +343,11 @@ const applyNodeObject = (node: NodeObject, container: Container) => { applyProps(el as HTMLElement, child.props, child.pP) applyNode(child, el as HTMLElement) } - if (childNodes[offset] !== el && childNodes[offset - 1] !== child.e) { + if ( + childNodes[offset] !== el && + childNodes[offset - 1] !== child.e && + child.tag !== HONO_PORTAL_ELEMENT + ) { container.insertBefore(el, childNodes[offset] || null) } } @@ -334,18 +358,19 @@ const applyNodeObject = (node: NodeObject, container: Container) => { }) } +const fallbackUpdateFnArrayMap = new WeakMap< + NodeObject, + Array<() => Promise> +>() export const build = ( context: Context, node: NodeObject, topLevelErrorHandlerNode: NodeObject | undefined, children?: Child[] ): void => { - if (node.tag === undefined) { - return - } - let errorHandler: ErrorHandler | undefined - children ||= typeof node.tag == 'function' ? invokeTag(context, node) : node.children + children ||= + typeof node.tag == 'function' ? invokeTag(context, node) : toArray(node.props.children) if ((children[0] as JSXNode)?.tag === '') { // eslint-disable-next-line @typescript-eslint/no-explicit-any errorHandler = (children[0] as any)[DOM_ERROR_HANDLER] as ErrorHandler @@ -364,7 +389,12 @@ export const build = ( } prevNode = child - if (typeof child.tag === 'function' && globalJSXContexts.length > 0) { + if ( + typeof child.tag === 'function' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + !(child.tag as any)[DOM_INTERNAL_TAG] && + globalJSXContexts.length > 0 + ) { child[DOM_STASH][2] = globalJSXContexts.map((c) => [c, c.values.at(-1)]) } @@ -380,9 +410,9 @@ export const build = ( if (!isNodeString(oldChild)) { vChildrenToRemove.push(oldChild) } else { - if (oldChild[0] !== child[0]) { - oldChild[0] = child[0] // update text content - oldChild[1] = true + if (oldChild.t !== child.t) { + oldChild.t = child.t // update text content + oldChild.d = true } child = oldChild } @@ -391,7 +421,9 @@ export const build = ( } else { oldChild.pP = oldChild.props oldChild.props = child.props - oldChild.children = child.children + if (typeof child.tag === 'function') { + oldChild[DOM_STASH][2] = child[DOM_STASH][2] || [] + } child = oldChild } } else if (!isNodeString(child) && nameSpaceContext) { @@ -412,9 +444,22 @@ export const build = ( node.vR = vChildrenToRemove } catch (e) { if (errorHandler) { - const fallback = errorHandler(e, () => + const fallbackUpdateFn = () => update([0, false, context[2] as UpdateHook], topLevelErrorHandlerNode as NodeObject) - ) + const fallbackUpdateFnArray = + fallbackUpdateFnArrayMap.get(topLevelErrorHandlerNode as NodeObject) || [] + fallbackUpdateFnArray.push(fallbackUpdateFn) + fallbackUpdateFnArrayMap.set(topLevelErrorHandlerNode as NodeObject, fallbackUpdateFnArray) + const fallback = errorHandler(e, () => { + const fnArray = fallbackUpdateFnArrayMap.get(topLevelErrorHandlerNode as NodeObject) + if (fnArray) { + const i = fnArray.indexOf(fallbackUpdateFn) + if (i !== -1) { + fnArray.splice(i, 1) + return fallbackUpdateFn() + } + } + }) if (fallback) { if (context[0] === 1) { context[1] = true @@ -432,7 +477,7 @@ const buildNode = (node: Child): Node | undefined => { if (node === undefined || node === null || typeof node === 'boolean') { return undefined } else if (typeof node === 'string' || typeof node === 'number') { - return [node.toString(), true] as NodeString + return { t: node.toString(), d: true } as NodeString } else { if (typeof (node as JSXNode).tag === 'function') { ;(node as NodeObject)[DOM_STASH] = [0, []] @@ -441,13 +486,13 @@ const buildNode = (node: Child): Node | undefined => { if (ns) { ;(node as NodeObject).n = ns nameSpaceContext ||= createContext('') - ;(node as JSXNode).children = [ + ;(node as JSXNode).props.children = [ { tag: nameSpaceContext.Provider, props: { value: ns, + children: (node as JSXNode).props.children, }, - children: (node as JSXNode).children, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as any @@ -479,6 +524,7 @@ const updateSync = (context: Context, node: NodeObject) => { type UpdateMapResolve = (node: NodeObject | undefined) => void const updateMap = new WeakMap() +const currentUpdateSets: Set[] = [] export const update = async ( context: Context, node: NodeObject @@ -505,19 +551,24 @@ export const update = async ( }, ]) - await Promise.resolve() + if (currentUpdateSets.length) { + ;(currentUpdateSets.at(-1) as Set).add(node) + } else { + await Promise.resolve() - const latest = updateMap.get(node) - if (latest) { - updateMap.delete(node) - latest[1]() + const latest = updateMap.get(node) + if (latest) { + updateMap.delete(node) + latest[1]() + } } return promise } export const render = (jsxNode: unknown, container: Container) => { - const node = buildNode({ tag: '', children: [jsxNode] } as JSXNode) as NodeObject + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const node = buildNode({ tag: '', props: { children: jsxNode } } as any) as NodeObject build([], node, undefined) const fragment = document.createDocumentFragment() @@ -525,3 +576,28 @@ export const render = (jsxNode: unknown, container: Container) => { replaceContainer(node, fragment, container) container.replaceChildren(fragment) } + +export const flushSync = (callback: () => void) => { + const set = new Set() + currentUpdateSets.push(set) + callback() + set.forEach((node) => { + const latest = updateMap.get(node) + if (latest) { + updateMap.delete(node) + latest[1]() + } + }) + currentUpdateSets.pop() +} + +export const createPortal = (children: Child, container: HTMLElement, key?: string): Child => + ({ + tag: HONO_PORTAL_ELEMENT, + props: { + children, + }, + key, + e: container, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) diff --git a/deno_dist/jsx/dom/utils.ts b/deno_dist/jsx/dom/utils.ts new file mode 100644 index 000000000..5e2340d0e --- /dev/null +++ b/deno_dist/jsx/dom/utils.ts @@ -0,0 +1,7 @@ +import { DOM_INTERNAL_TAG } from '../constants.ts' + +export const setInternalTagFlag = (fn: Function) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(fn as any)[DOM_INTERNAL_TAG] = true + return fn +} diff --git a/deno_dist/jsx/hooks/index.ts b/deno_dist/jsx/hooks/index.ts index c8bfa7b61..b3e77533a 100644 --- a/deno_dist/jsx/hooks/index.ts +++ b/deno_dist/jsx/hooks/index.ts @@ -166,7 +166,7 @@ export const useState: UseStateType = ( newState = (newState as (currentState: T) => T)(stateData[0]) } - if (newState !== stateData[0]) { + if (!Object.is(newState, stateData[0])) { stateData[0] = newState if (pendingStack.length) { const pendingType = pendingStack.at(-1) as PendingType @@ -361,3 +361,52 @@ export const useId = (): string => useMemo(() => `:r${(idCounter++).toString(32) // Define to avoid errors. This hook currently does nothing. // eslint-disable-next-line @typescript-eslint/no-unused-vars export const useDebugValue = (_value: unknown, _formatter?: (value: unknown) => string): void => {} + +export const createRef = (): RefObject => { + return { current: null } +} + +export const forwardRef = ( + Component: (props: P, ref: RefObject) => JSX.Element +): ((props: P & { ref: RefObject }) => JSX.Element) => { + return (props) => { + const { ref, ...rest } = props + return Component(rest as P, ref) + } +} + +export const useImperativeHandle = ( + ref: RefObject, + createHandle: () => T, + deps: readonly unknown[] +): void => { + useEffect(() => { + ref.current = createHandle() + return () => { + ref.current = null + } + }, deps) +} + +let useSyncExternalStoreGetServerSnapshotNotified = false +export const useSyncExternalStore = ( + subscribe: (callback: (value: T) => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T +): T => { + const [state, setState] = useState(getSnapshot()) + useEffect( + () => + subscribe(() => { + setState(getSnapshot()) + }), + [] + ) + + if (getServerSnapshot && !useSyncExternalStoreGetServerSnapshotNotified) { + useSyncExternalStoreGetServerSnapshotNotified = true + console.info('`getServerSnapshot` is not supported yet.') + } + + return state +} diff --git a/deno_dist/jsx/index.ts b/deno_dist/jsx/index.ts index 9cb9e0017..005c04b96 100644 --- a/deno_dist/jsx/index.ts +++ b/deno_dist/jsx/index.ts @@ -1,4 +1,6 @@ import { jsx, memo, Fragment, isValidElement, cloneElement } from './base.ts' +import type { DOMAttributes } from './base.ts' +import { Children } from './children.ts' import { ErrorBoundary } from './components.ts' import { createContext, useContext } from './context.ts' import { @@ -17,6 +19,10 @@ import { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, } from './hooks/index.ts' import { Suspense } from './streaming.ts' @@ -45,7 +51,13 @@ export { useViewTransition, useMemo, useLayoutEffect, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, + Children, + DOMAttributes, } export default { @@ -72,7 +84,12 @@ export default { useViewTransition, useMemo, useLayoutEffect, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, + Children, } // TODO: change to `export type *` after denoify bug is fixed diff --git a/deno_dist/jsx/jsx-dev-runtime.ts b/deno_dist/jsx/jsx-dev-runtime.ts index 52714dc0a..39492291d 100644 --- a/deno_dist/jsx/jsx-dev-runtime.ts +++ b/deno_dist/jsx/jsx-dev-runtime.ts @@ -13,7 +13,6 @@ export function jsxDEV( node = jsxFn(tag, props, []) } else { const children = props.children as string | HtmlEscapedString - delete props['children'] node = Array.isArray(children) ? jsxFn(tag, props, children) : jsxFn(tag, props, [children]) } node.key = key diff --git a/deno_dist/jsx/types.ts b/deno_dist/jsx/types.ts index 18b124ce4..3e1daf437 100644 --- a/deno_dist/jsx/types.ts +++ b/deno_dist/jsx/types.ts @@ -1,7 +1,7 @@ /** * All types exported from "hono/jsx" are in this file. */ -import type { Child } from './base.ts' +import type { Child, JSXNode } from './base.ts' export type { Child, JSXNode, FC } from './base.ts' export type { RefObject } from './hooks/index.ts' @@ -9,3 +9,32 @@ export type { Context } from './context.ts' export type PropsWithChildren

= P & { children?: Child | undefined } export type CSSProperties = Hono.CSSProperties + +/** + * React types + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ReactElement

= JSXNode & { + type: T + props: P + key: string | null +} +type ReactNode = ReactElement | string | number | boolean | null | undefined +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type ComponentClass

= unknown + +export type { ReactElement, ReactNode, ComponentClass } + +export type Event = globalThis.Event +export type MouseEvent = globalThis.MouseEvent +export type KeyboardEvent = globalThis.KeyboardEvent +export type FocusEvent = globalThis.FocusEvent +export type ClipboardEvent = globalThis.ClipboardEvent +export type InputEvent = globalThis.InputEvent +export type PointerEvent = globalThis.PointerEvent +export type TouchEvent = globalThis.TouchEvent +export type WheelEvent = globalThis.WheelEvent +export type AnimationEvent = globalThis.AnimationEvent +export type TransitionEvent = globalThis.TransitionEvent +export type DragEvent = globalThis.DragEvent diff --git a/deno_dist/jsx/utils.ts b/deno_dist/jsx/utils.ts index bb04288a8..dc8926ace 100644 --- a/deno_dist/jsx/utils.ts +++ b/deno_dist/jsx/utils.ts @@ -4,3 +4,17 @@ export const normalizeIntrinsicElementProps = (props: Record): delete props['className'] } } + +export const styleObjectForEach = ( + style: Record, + fn: (key: string, value: string | null) => void +): void => { + for (const [k, v] of Object.entries(style)) { + fn( + k[0] === '-' + ? k // CSS variable + : k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`), // style property. convert to kebab-case + v == null ? null : typeof v === 'number' ? v + 'px' : (v as string) + ) + } +} diff --git a/src/jsx/base.ts b/src/jsx/base.ts index e9dc9719e..15e1477d0 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -4,11 +4,16 @@ import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html import type { Context } from './context' import { globalContexts } from './context' import type { IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements' -import { normalizeIntrinsicElementProps } from './utils' +import { normalizeIntrinsicElementProps, styleObjectForEach } from './utils' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Props = Record -export type FC = (props: T) => HtmlEscapedString | Promise +export type FC

= { + (props: P): HtmlEscapedString | Promise + defaultProps?: Partial

| undefined + displayName?: string | undefined +} +export type DOMAttributes = Hono.HTMLAttributes declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -114,6 +119,16 @@ export class JSXNode implements HtmlEscaped { this.children = children } + get type(): string | Function { + return this.tag as string + } + + // Added for compatibility with libraries that rely on React's internal structure + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get ref(): any { + return this.props.ref || null + } + toString(): string | Promise { const buffer: StringBuffer = [''] this.localContexts?.forEach(([context, value]) => { @@ -141,16 +156,18 @@ export class JSXNode implements HtmlEscaped { for (let i = 0, len = propsKeys.length; i < len; i++) { const key = propsKeys[i] const v = props[key] - // object to style strings - if (key === 'style' && typeof v === 'object') { - const styles = Object.keys(v) - .map((k) => { - const property = k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) - return `${property}:${v[k]}` - }) - .join(';') + if (key === 'children') { + // skip children + } else if (key === 'style' && typeof v === 'object') { + // object to style strings + let styleStr = '' + styleObjectForEach(v, (property, value) => { + if (value != null) { + styleStr += `${styleStr ? ';' : ''}${property}:${value}` + } + }) buffer[0] += ' style="' - escapeToBuffer(styles, buffer) + escapeToBuffer(styleStr, buffer) buffer[0] += '"' } else if (typeof v === 'string') { buffer[0] += ` ${key}="` @@ -241,14 +258,17 @@ export class JSXFragmentNode extends JSXNode { export const jsx = ( tag: string | Function, - props: Props, - ...children: (string | HtmlEscapedString)[] + props: Props | null, + ...children: (string | number | HtmlEscapedString)[] ): JSXNode => { - let key - if (props) { - key = props?.key - delete props['key'] + props ??= {} + if (children.length) { + props.children = children.length === 1 ? children[0] : children } + + const key = props.key + delete props['key'] + const node = jsxFn(tag, props, children) node.key = key return node @@ -257,7 +277,7 @@ export const jsx = ( export const jsxFn = ( tag: string | Function, props: Props, - children: (string | HtmlEscapedString)[] + children: (string | number | HtmlEscapedString)[] ): JSXNode => { if (typeof tag === 'function') { return new JSXFunctionNode(tag, props, children) @@ -317,19 +337,15 @@ export const Fragment = ({ }): HtmlEscapedString => { return new JSXFragmentNode( '', - {}, + { + children, + }, Array.isArray(children) ? children : children ? [children] : [] ) as never } export const isValidElement = (element: unknown): element is JSXNode => { - return !!( - element && - typeof element === 'object' && - 'tag' in element && - 'props' in element && - 'children' in element - ) + return !!(element && typeof element === 'object' && 'tag' in element && 'props' in element) } export const cloneElement = ( @@ -337,10 +353,9 @@ export const cloneElement = ( props: Partial, ...children: Child[] ): T => { - return jsxFn( + return jsx( (element as JSXNode).tag, { ...(element as JSXNode).props, ...props }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - children.length ? children : ((element as JSXNode).children as any) || [] + ...(children as (string | number | HtmlEscapedString)[]) ) as T } diff --git a/src/jsx/children.test.ts b/src/jsx/children.test.ts new file mode 100644 index 000000000..852e11107 --- /dev/null +++ b/src/jsx/children.test.ts @@ -0,0 +1,52 @@ +import { Children } from './children' +import { createElement } from '.' + +describe('map', () => { + it('should map children', () => { + const element = createElement('div', null, 1, 2, 3) + const result = Children.map(element.children, (child) => (child as number) * 2) + expect(result).toEqual([2, 4, 6]) + }) +}) + +describe('forEach', () => { + it('should iterate children', () => { + const element = createElement('div', null, 1, 2, 3) + const result: number[] = [] + Children.forEach(element.children, (child) => { + result.push(child as number) + }) + expect(result).toEqual([1, 2, 3]) + }) +}) + +describe('count', () => { + it('should count children', () => { + const element = createElement('div', null, 1, 2, 3) + const result = Children.count(element.children) + expect(result).toBe(3) + }) +}) + +describe('only', () => { + it('should return the only child', () => { + const element = createElement('div', null, 1) + const result = Children.only(element.children) + expect(result).toBe(1) + }) + + it('should throw an error if there are multiple children', () => { + const element = createElement('div', null, 1, 2) + expect(() => Children.only(element.children)).toThrowError( + 'Children.only() expects only one child' + ) + }) +}) + +describe('toArray', () => { + it('should convert children to an array', () => { + const element = createElement('div', null, 1, 2, 3) + const result = Children.toArray(element.children) + expect(result).toEqual([1, 2, 3]) + }) +}) diff --git a/src/jsx/children.ts b/src/jsx/children.ts new file mode 100644 index 000000000..eeb954a8c --- /dev/null +++ b/src/jsx/children.ts @@ -0,0 +1,20 @@ +import type { Child } from './base' + +export const toArray = (children: Child): Child[] => + Array.isArray(children) ? children : [children] +export const Children = { + map: (children: Child[], fn: (child: Child, index: number) => Child): Child[] => + toArray(children).map(fn), + forEach: (children: Child[], fn: (child: Child, index: number) => void): void => { + toArray(children).forEach(fn) + }, + count: (children: Child[]): number => toArray(children).length, + only: (_children: Child[]): Child => { + const children = toArray(_children) + if (children.length !== 1) { + throw new Error('Children.only() expects only one child') + } + return children[0] + }, + toArray, +} diff --git a/src/jsx/constants.ts b/src/jsx/constants.ts index 1762ad046..1c5a37df7 100644 --- a/src/jsx/constants.ts +++ b/src/jsx/constants.ts @@ -1,3 +1,4 @@ export const DOM_RENDERER = Symbol('RENDERER') export const DOM_ERROR_HANDLER = Symbol('ERROR_HANDLER') export const DOM_STASH = Symbol('STASH') +export const DOM_INTERNAL_TAG = Symbol('INTERNAL') diff --git a/src/jsx/dom/context.test.tsx b/src/jsx/dom/context.test.tsx index 3360ae5b8..8ab887389 100644 --- a/src/jsx/dom/context.test.tsx +++ b/src/jsx/dom/context.test.tsx @@ -6,7 +6,7 @@ import { use, Suspense } from '..' // eslint-disable-next-line @typescript-eslint/no-unused-vars import { jsx, Fragment } from '..' import { createContext as createContextDom, useContext as useContextDom } from '.' // for dom -import { render } from '.' +import { render, useState } from '.' runner('Common', createContextCommon, useContextCommon) runner('DOM', createContextDom, useContextDom) @@ -52,6 +52,35 @@ function runner( expect(root.innerHTML).toBe('

1

') }) + it('simple context with state', async () => { + const Context = createContext(0) + const Content = () => { + const [count, setCount] = useState(0) + const num = useContext(Context) + return ( + <> +

+ {num} - {count} +

+ + + ) + } + const Component = () => { + return ( + + + + ) + } + const App = + render(App, root) + expect(root.innerHTML).toBe('

1 - 0

') + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('

1 - 1

') + }) + it('multiple provider', async () => { const Context = createContext(0) const Content = () => { diff --git a/src/jsx/dom/context.ts b/src/jsx/dom/context.ts index 6b4a3adbd..c1a3d8b33 100644 --- a/src/jsx/dom/context.ts +++ b/src/jsx/dom/context.ts @@ -3,32 +3,44 @@ import { DOM_ERROR_HANDLER } from '../constants' import type { Context } from '../context' import { globalContexts } from '../context' import { Fragment } from './jsx-runtime' +import { setInternalTagFlag } from './utils' -export const createContextProviderFunction = - (values: T[]) => - ({ value, children }: { value: T; children: Child[] }) => { - const res = Fragment({ +export const createContextProviderFunction = (values: T[]) => + setInternalTagFlag(({ value, children }: { value: T; children: Child[] }) => { + if (!children) { + return undefined + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props: { children: any } = { children: [ { - tag: () => { + tag: setInternalTagFlag(() => { values.push(value) - }, - }, - ...(children as Child[]), - { - tag: () => { - values.pop() - }, + }), + props: {}, }, ], + } + if (Array.isArray(children)) { + props.children.push(...children.flat()) + } else { + props.children.push(children) + } + props.children.push({ + tag: setInternalTagFlag(() => { + values.pop() + }), + props: {}, }) + const res = Fragment(props) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(res as any)[DOM_ERROR_HANDLER] = (err: unknown) => { values.pop() throw err } return res - } + }) export const createContext = (defaultValue: T): Context => { const values = [defaultValue] diff --git a/src/jsx/dom/css.ts b/src/jsx/dom/css.ts index b018dba01..6edcabb87 100644 --- a/src/jsx/dom/css.ts +++ b/src/jsx/dom/css.ts @@ -110,10 +110,14 @@ export const createCssJsxDomObjects = ({ id }: { id: Readonly }) => { const Style: FC> = ({ children }) => ({ tag: 'style', - children: (Array.isArray(children) ? children : [children]).map( - (c) => (c as unknown as CssClassName)[STYLE_STRING] - ), - props: { id }, + props: { + id, + children: + children && + (Array.isArray(children) ? children : [children]).map( + (c) => (c as unknown as CssClassName)[STYLE_STRING] + ), + }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) diff --git a/src/jsx/dom/index.test.tsx b/src/jsx/dom/index.test.tsx index 9110a9b4d..1c601f7d6 100644 --- a/src/jsx/dom/index.test.tsx +++ b/src/jsx/dom/index.test.tsx @@ -4,11 +4,77 @@ import type { FC } from '..' // hono/jsx/jsx-runtime and hono/jsx/dom/jsx-runtime are tested in their respective settings // eslint-disable-next-line @typescript-eslint/no-unused-vars import { jsx, Fragment, createElement } from '..' -import DefaultExport from '..' import type { RefObject } from '../hooks' -import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from '../hooks' +import { + useState, + useEffect, + useLayoutEffect, + useCallback, + useRef, + useMemo, + createRef, +} from '../hooks' +import DefaultExport from '.' import { memo, isValidElement, cloneElement } from '.' -import { render, createElement as createElementForDom, cloneElement as cloneElementForDom } from '.' +import { + render, + flushSync, + createPortal, + createElement as createElementForDom, + cloneElement as cloneElementForDom, +} from '.' + +describe('Common', () => { + ;[createElement, createElementForDom].forEach((createElement) => { + describe('createElement', () => { + it('simple', () => { + const element = createElement('div', { id: 'app' }) + expect(element).toEqual(expect.objectContaining({ tag: 'div', props: { id: 'app' } })) + }) + + it('children', () => { + const element = createElement('div', { id: 'app' }, 'Hello') + expect(element).toEqual( + expect.objectContaining({ tag: 'div', props: { id: 'app', children: 'Hello' } }) + ) + }) + + it('multiple children', () => { + const element = createElement('div', { id: 'app' }, 'Hello', 'World') + expect(element).toEqual( + expect.objectContaining({ + tag: 'div', + props: { id: 'app', children: ['Hello', 'World'] }, + }) + ) + }) + + it('key', () => { + const element = createElement('div', { id: 'app', key: 'key' }) + expect(element).toEqual( + expect.objectContaining({ tag: 'div', props: { id: 'app' }, key: 'key' }) + ) + }) + + it('ref', () => { + const ref = { current: null } + const element = createElement('div', { id: 'app', ref }) + expect(element).toEqual(expect.objectContaining({ tag: 'div', props: { id: 'app', ref } })) + expect(element.ref).toBe(ref) + }) + + it('type', () => { + const element = createElement('div', { id: 'app' }) + expect(element.type).toBe('div') + }) + + it('null props', () => { + const element = createElement('div', null) + expect(element).toEqual(expect.objectContaining({ tag: 'div', props: {} })) + }) + }) + }) +}) describe('DOM', () => { beforeAll(() => { @@ -72,6 +138,18 @@ describe('DOM', () => { expect(root.innerHTML).toBe('
') }) + it('style with CSS variables - 1', () => { + const App = () =>
+ render(, root) + expect(root.innerHTML).toBe('
') + }) + + it('style with CSS variables - 2', () => { + const App = () =>
+ render(, root) + expect(root.innerHTML).toBe('
') + }) + it('style with string', async () => { const App = () => { const [style, setStyle] = useState<{ fontSize?: string; color?: string }>({ @@ -110,6 +188,70 @@ describe('DOM', () => { expect(root.innerHTML).toBe('
') expect(ref.current).toBeInstanceOf(HTMLElement) }) + + it('ref with null', () => { + const App = () => { + return
+ } + render(, root) + expect(root.innerHTML).toBe('
') + }) + + it('remove node with ref object', async () => { + const ref = createRef() + const App = () => { + const [show, setShow] = useState(true) + return ( + <> + {show &&
} + + + ) + } + render(, root) + expect(root.innerHTML).toBe('
') + expect(ref.current).toBeInstanceOf(dom.window.HTMLDivElement) + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('') + expect(ref.current).toBe(null) + }) + + it('remove node with ref function', async () => { + const ref = vi.fn() + const App = () => { + const [show, setShow] = useState(true) + return ( + <> + {show &&
} + + + ) + } + render(, root) + expect(root.innerHTML).toBe('
') + expect(ref).toHaveBeenLastCalledWith(expect.any(dom.window.HTMLDivElement)) + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('') + expect(ref).toHaveBeenLastCalledWith(null) + }) + }) + + describe('defaultProps', () => { + it('simple', () => { + const App: FC<{ name?: string }> = ({ name }) =>
{name}
+ App.defaultProps = { name: 'default' } + render(, root) + expect(root.innerHTML).toBe('
default
') + }) + + it('override', () => { + const App: FC<{ name: string }> = ({ name }) =>
{name}
+ App.defaultProps = { name: 'default' } + render(, root) + expect(root.innerHTML).toBe('
override
') + }) }) describe('replace content', () => { @@ -612,6 +754,7 @@ describe('DOM', () => { false ) }) + it('onGotPointerCaptureCapture', async () => { const App = () => { return
{}}>
@@ -625,6 +768,15 @@ describe('DOM', () => { true ) }) + + it('undefined', async () => { + const App = () => { + return
+ } + const addEventListenerSpy = vi.spyOn(dom.window.Node.prototype, 'addEventListener') + render(, root) + expect(addEventListenerSpy).not.toHaveBeenCalled() + }) }) it('simple Counter', async () => { @@ -1393,6 +1545,73 @@ describe('DOM', () => { }) }) + describe('flushSync', () => { + it('simple', async () => { + const SubApp = ({ id }: { id: string }) => { + const [count, setCount] = useState(0) + return ( +
+

{count}

+ +
+ ) + } + const App = () => { + return ( +
+ + +
+ ) + } + const app = + render(app, root) + expect(root.innerHTML).toBe( + '

0

0

' + ) + root.querySelector('#b button')?.click() + flushSync(() => { + root.querySelector('#a button')?.click() + }) + expect(root.innerHTML).toBe( + '

1

0

' + ) + await Promise.resolve() + expect(root.innerHTML).toBe( + '

1

1

' + ) + }) + }) + + describe('createPortal', () => { + it('simple', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ + {count <= 1 && createPortal(

{count}

, document.body)} +
+ ) + } + const app = + render(app, root) + expect(root.innerHTML).toBe('
') + expect(document.body.innerHTML).toBe( + '

0

' + ) + document.body.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
') + expect(document.body.innerHTML).toBe( + '

1

' + ) + document.body.querySelector('button')?.click() + await Promise.resolve() + expect(document.body.innerHTML).toBe('
') + }) + }) + describe('SVG', () => { it('simple', () => { const App = () => { @@ -1475,6 +1694,10 @@ describe('default export', () => { 'useCallback', 'useReducer', 'useDebugValue', + 'createRef', + 'forwardRef', + 'useImperativeHandle', + 'useSyncExternalStore', 'use', 'startTransition', 'useTransition', @@ -1484,6 +1707,9 @@ describe('default export', () => { 'useMemo', 'useLayoutEffect', 'Suspense', + 'Fragment', + 'flushSync', + 'createPortal', ].forEach((key) => { it(key, () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 8b202a437..3612119da 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -1,5 +1,6 @@ -import type { Props, Child, JSXNode } from '../base' +import type { Props, Child, DOMAttributes, JSXNode } from '../base' import { memo, isValidElement } from '../base' +import { Children } from '../children' import { useContext } from '../context' import { useState, @@ -17,19 +18,28 @@ import { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, } from '../hooks' import { Suspense, ErrorBoundary } from './components' import { createContext } from './context' -import { jsx } from './jsx-runtime' +import { jsx, Fragment } from './jsx-runtime' +import { flushSync, createPortal } from './render' export { render } from './render' const createElement = ( tag: string | ((props: Props) => JSXNode), - props: Props, + props: Props | null, ...children: Child[] ): JSXNode => { - const jsxProps: Props = { ...props, children } + const jsxProps: Props = props ? { ...props } : {} + if (children.length) { + jsxProps.children = children.length === 1 ? children[0] : children + } + let key = undefined if ('key' in jsxProps) { key = jsxProps.key @@ -49,7 +59,7 @@ const cloneElement = ( { ...(element as JSXNode).props, ...props, - children: children.length ? children : (element as JSXNode).children, + children: children.length ? children : (element as JSXNode).props.children, }, (element as JSXNode).key ) as T @@ -72,6 +82,10 @@ export { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, ErrorBoundary, createContext, @@ -80,6 +94,11 @@ export { isValidElement, createElement, cloneElement, + Children, + Fragment, + DOMAttributes, + flushSync, + createPortal, } export default { @@ -98,6 +117,10 @@ export default { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, ErrorBoundary, createContext, @@ -106,6 +129,14 @@ export default { isValidElement, createElement, cloneElement, + Children, + Fragment, + flushSync, + createPortal, } export type { Context } from '../context' + +// TODO: change to `export type *` after denoify bug is fixed +// https://github.com/garronej/denoify/issues/124 +export * from '../types' diff --git a/src/jsx/dom/jsx-dev-runtime.ts b/src/jsx/dom/jsx-dev-runtime.ts index fa5141a65..28b9acdc9 100644 --- a/src/jsx/dom/jsx-dev-runtime.ts +++ b/src/jsx/dom/jsx-dev-runtime.ts @@ -1,23 +1,31 @@ -import type { Props } from '../base' +import type { Props, JSXNode } from '../base' import { normalizeIntrinsicElementProps } from '../utils' -export const jsxDEV = (tag: string | Function, props: Props, key?: string) => { +const JSXNodeCompatPrototype = { + type: { + get(this: { tag: string | Function }): string | Function { + return this.tag + }, + }, + ref: { + get(this: { props?: { ref: unknown } }): unknown { + return this.props?.ref + }, + }, +} + +export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXNode => { if (typeof tag === 'string') { normalizeIntrinsicElementProps(props) } - let children - if (props && 'children' in props) { - children = props.children - delete props['children'] - } else { - children = [] - } - return { - tag, - props, - key, - children: Array.isArray(children) ? children : [children], - } + return Object.defineProperties( + { + tag, + props, + key, + }, + JSXNodeCompatPrototype + ) as JSXNode } export const Fragment = (props: Record) => jsxDEV('', props, undefined) diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 4b3fc6eba..dcc0f3cc3 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -1,12 +1,16 @@ import type { JSXNode } from '../base' import type { FC, Child, Props } from '../base' -import { DOM_RENDERER, DOM_ERROR_HANDLER, DOM_STASH } from '../constants' +import { toArray } from '../children' +import { DOM_RENDERER, DOM_ERROR_HANDLER, DOM_STASH, DOM_INTERNAL_TAG } from '../constants' import type { Context as JSXContext } from '../context' import { globalContexts as globalJSXContexts, useContext } from '../context' import type { EffectData } from '../hooks' import { STASH_EFFECT } from '../hooks' +import { styleObjectForEach } from '../utils' import { createContext } from './context' // import dom-specific versions +const HONO_PORTAL_ELEMENT = '_hp' + const eventAliasMap: Record = { Change: 'Input', DoubleClick: 'DblClick', @@ -17,6 +21,8 @@ const nameSpaceMap: Record = { math: 'http://www.w3.org/1998/Math/MathML', } as const +const skipProps = new Set(['children']) + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type HasRenderToDom = FC & { [DOM_RENDERER]: FC } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -48,10 +54,10 @@ export type NodeObject = { any[][] ] } & JSXNode -type NodeString = [ - string, // text content - boolean // is dirty -] & { +type NodeString = { + t: string // text content + d: boolean // is dirty +} & { e?: Text // like a NodeObject vC: undefined @@ -87,7 +93,7 @@ export const buildDataStack: [Context, Node][] = [] let nameSpaceContext: JSXContext | undefined = undefined -const isNodeString = (node: Node): node is NodeString => Array.isArray(node) +const isNodeString = (node: Node): node is NodeString => 't' in (node as NodeString) const getEventSpec = (key: string): [string, boolean] | undefined => { const match = key.match(/^on([A-Z][a-zA-Z]+?(?:PointerCapture)?)(Capture)?$/) @@ -101,31 +107,35 @@ const getEventSpec = (key: string): [string, boolean] | undefined => { const applyProps = (container: SupportedElement, attributes: Props, oldAttributes?: Props) => { attributes ||= {} for (const [key, value] of Object.entries(attributes)) { - if (!oldAttributes || oldAttributes[key] !== value) { + if (!skipProps.has(key) && (!oldAttributes || oldAttributes[key] !== value)) { const eventSpec = getEventSpec(key) if (eventSpec) { - if (typeof value !== 'function') { - throw new Error(`Event handler for "${key}" is not a function`) - } - if (oldAttributes) { container.removeEventListener(eventSpec[0], oldAttributes[key], eventSpec[1]) } - container.addEventListener(eventSpec[0], value, eventSpec[1]) + if (value != null) { + if (typeof value !== 'function') { + throw new Error(`Event handler for "${key}" is not a function`) + } + container.addEventListener(eventSpec[0], value, eventSpec[1]) + } } else if (key === 'dangerouslySetInnerHTML' && value) { container.innerHTML = value.__html } else if (key === 'ref') { if (typeof value === 'function') { value(container) - } else if ('current' in value) { + } else if (value && 'current' in value) { value.current = container } } else if (key === 'style') { + const style = container.style if (typeof value === 'string') { - container.style.cssText = value + style.cssText = value } else { - container.style.cssText = '' - Object.assign(container.style, value) + style.cssText = '' + if (value != null) { + styleObjectForEach(value, style.setProperty.bind(style)) + } } } else { const nodeName = container.nodeName @@ -166,7 +176,7 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute } if (oldAttributes) { for (const [key, value] of Object.entries(oldAttributes)) { - if (!(key in attributes)) { + if (!skipProps.has(key) && !(key in attributes)) { const eventSpec = getEventSpec(key) if (eventSpec) { container.removeEventListener(eventSpec[0], value, eventSpec[1]) @@ -197,8 +207,9 @@ const invokeTag = (context: Context, node: NodeObject): Child[] => { try { return [ func.call(null, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...((func as any).defaultProps || {}), ...node.props, - children: node.children, }), ] } finally { @@ -254,24 +265,33 @@ const findInsertBefore = (node: Node | undefined): ChildNode | null => { const removeNode = (node: Node) => { if (!isNodeString(node)) { node[DOM_STASH]?.[1][STASH_EFFECT]?.forEach((data: EffectData) => data[2]?.()) + + if (node.e && node.props?.ref) { + if (typeof node.props.ref === 'function') { + node.props.ref(null) + } else { + node.props.ref.current = null + } + } node.vC?.forEach(removeNode) } - node.e?.remove() - node.tag = undefined + if (node.tag !== HONO_PORTAL_ELEMENT) { + node.e?.remove() + } + if (typeof node.tag === 'function') { + updateMap.delete(node) + fallbackUpdateFnArrayMap.delete(node) + } } const apply = (node: NodeObject, container: Container) => { - if (node.tag === undefined) { - return - } - node.c = container applyNodeObject(node, container) } const applyNode = (node: Node, container: Container) => { if (isNodeString(node)) { - container.textContent = node[0] + container.textContent = node.t } else { applyNodeObject(node, container) } @@ -311,11 +331,11 @@ const applyNodeObject = (node: NodeObject, container: Container) => { let el: SupportedElement | Text if (isNodeString(child)) { - if (child.e && child[1]) { - child.e.textContent = child[0] + if (child.e && child.d) { + child.e.textContent = child.t } - child[1] = false - el = child.e ||= document.createTextNode(child[0]) + child.d = false + el = child.e ||= document.createTextNode(child.t) } else { el = child.e ||= child.n ? (document.createElementNS(child.n, child.tag as string) as SVGElement | MathMLElement) @@ -323,7 +343,11 @@ const applyNodeObject = (node: NodeObject, container: Container) => { applyProps(el as HTMLElement, child.props, child.pP) applyNode(child, el as HTMLElement) } - if (childNodes[offset] !== el && childNodes[offset - 1] !== child.e) { + if ( + childNodes[offset] !== el && + childNodes[offset - 1] !== child.e && + child.tag !== HONO_PORTAL_ELEMENT + ) { container.insertBefore(el, childNodes[offset] || null) } } @@ -334,18 +358,19 @@ const applyNodeObject = (node: NodeObject, container: Container) => { }) } +const fallbackUpdateFnArrayMap = new WeakMap< + NodeObject, + Array<() => Promise> +>() export const build = ( context: Context, node: NodeObject, topLevelErrorHandlerNode: NodeObject | undefined, children?: Child[] ): void => { - if (node.tag === undefined) { - return - } - let errorHandler: ErrorHandler | undefined - children ||= typeof node.tag == 'function' ? invokeTag(context, node) : node.children + children ||= + typeof node.tag == 'function' ? invokeTag(context, node) : toArray(node.props.children) if ((children[0] as JSXNode)?.tag === '') { // eslint-disable-next-line @typescript-eslint/no-explicit-any errorHandler = (children[0] as any)[DOM_ERROR_HANDLER] as ErrorHandler @@ -364,7 +389,12 @@ export const build = ( } prevNode = child - if (typeof child.tag === 'function' && globalJSXContexts.length > 0) { + if ( + typeof child.tag === 'function' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + !(child.tag as any)[DOM_INTERNAL_TAG] && + globalJSXContexts.length > 0 + ) { child[DOM_STASH][2] = globalJSXContexts.map((c) => [c, c.values.at(-1)]) } @@ -380,9 +410,9 @@ export const build = ( if (!isNodeString(oldChild)) { vChildrenToRemove.push(oldChild) } else { - if (oldChild[0] !== child[0]) { - oldChild[0] = child[0] // update text content - oldChild[1] = true + if (oldChild.t !== child.t) { + oldChild.t = child.t // update text content + oldChild.d = true } child = oldChild } @@ -391,7 +421,9 @@ export const build = ( } else { oldChild.pP = oldChild.props oldChild.props = child.props - oldChild.children = child.children + if (typeof child.tag === 'function') { + oldChild[DOM_STASH][2] = child[DOM_STASH][2] || [] + } child = oldChild } } else if (!isNodeString(child) && nameSpaceContext) { @@ -412,9 +444,22 @@ export const build = ( node.vR = vChildrenToRemove } catch (e) { if (errorHandler) { - const fallback = errorHandler(e, () => + const fallbackUpdateFn = () => update([0, false, context[2] as UpdateHook], topLevelErrorHandlerNode as NodeObject) - ) + const fallbackUpdateFnArray = + fallbackUpdateFnArrayMap.get(topLevelErrorHandlerNode as NodeObject) || [] + fallbackUpdateFnArray.push(fallbackUpdateFn) + fallbackUpdateFnArrayMap.set(topLevelErrorHandlerNode as NodeObject, fallbackUpdateFnArray) + const fallback = errorHandler(e, () => { + const fnArray = fallbackUpdateFnArrayMap.get(topLevelErrorHandlerNode as NodeObject) + if (fnArray) { + const i = fnArray.indexOf(fallbackUpdateFn) + if (i !== -1) { + fnArray.splice(i, 1) + return fallbackUpdateFn() + } + } + }) if (fallback) { if (context[0] === 1) { context[1] = true @@ -432,7 +477,7 @@ const buildNode = (node: Child): Node | undefined => { if (node === undefined || node === null || typeof node === 'boolean') { return undefined } else if (typeof node === 'string' || typeof node === 'number') { - return [node.toString(), true] as NodeString + return { t: node.toString(), d: true } as NodeString } else { if (typeof (node as JSXNode).tag === 'function') { ;(node as NodeObject)[DOM_STASH] = [0, []] @@ -441,13 +486,13 @@ const buildNode = (node: Child): Node | undefined => { if (ns) { ;(node as NodeObject).n = ns nameSpaceContext ||= createContext('') - ;(node as JSXNode).children = [ + ;(node as JSXNode).props.children = [ { tag: nameSpaceContext.Provider, props: { value: ns, + children: (node as JSXNode).props.children, }, - children: (node as JSXNode).children, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as any @@ -479,6 +524,7 @@ const updateSync = (context: Context, node: NodeObject) => { type UpdateMapResolve = (node: NodeObject | undefined) => void const updateMap = new WeakMap() +const currentUpdateSets: Set[] = [] export const update = async ( context: Context, node: NodeObject @@ -505,19 +551,24 @@ export const update = async ( }, ]) - await Promise.resolve() + if (currentUpdateSets.length) { + ;(currentUpdateSets.at(-1) as Set).add(node) + } else { + await Promise.resolve() - const latest = updateMap.get(node) - if (latest) { - updateMap.delete(node) - latest[1]() + const latest = updateMap.get(node) + if (latest) { + updateMap.delete(node) + latest[1]() + } } return promise } export const render = (jsxNode: unknown, container: Container) => { - const node = buildNode({ tag: '', children: [jsxNode] } as JSXNode) as NodeObject + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const node = buildNode({ tag: '', props: { children: jsxNode } } as any) as NodeObject build([], node, undefined) const fragment = document.createDocumentFragment() @@ -525,3 +576,28 @@ export const render = (jsxNode: unknown, container: Container) => { replaceContainer(node, fragment, container) container.replaceChildren(fragment) } + +export const flushSync = (callback: () => void) => { + const set = new Set() + currentUpdateSets.push(set) + callback() + set.forEach((node) => { + const latest = updateMap.get(node) + if (latest) { + updateMap.delete(node) + latest[1]() + } + }) + currentUpdateSets.pop() +} + +export const createPortal = (children: Child, container: HTMLElement, key?: string): Child => + ({ + tag: HONO_PORTAL_ELEMENT, + props: { + children, + }, + key, + e: container, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) diff --git a/src/jsx/dom/utils.ts b/src/jsx/dom/utils.ts new file mode 100644 index 000000000..cf4c58072 --- /dev/null +++ b/src/jsx/dom/utils.ts @@ -0,0 +1,7 @@ +import { DOM_INTERNAL_TAG } from '../constants' + +export const setInternalTagFlag = (fn: Function) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(fn as any)[DOM_INTERNAL_TAG] = true + return fn +} diff --git a/src/jsx/hooks/dom.test.tsx b/src/jsx/hooks/dom.test.tsx index 491d7514b..c5767daf7 100644 --- a/src/jsx/hooks/dom.test.tsx +++ b/src/jsx/hooks/dom.test.tsx @@ -16,6 +16,10 @@ import { useViewTransition, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, } from '.' describe('Hooks', () => { @@ -470,4 +474,94 @@ describe('Hooks', () => { expect(spy).not.toBeCalled() }) }) + + describe('createRef()', () => { + it('simple', () => { + const ref: { current: HTMLElement | null } = createRef() + const App = () => { + return
+ } + render(, root) + expect(root.innerHTML).toBe('
') + expect(ref.current).toBeInstanceOf(HTMLElement) + }) + }) + + describe('forwardRef()', () => { + it('simple', () => { + const ref: { current: HTMLElement | null } = createRef() + const App = forwardRef((props, ref) => { + return
+ }) + render(, root) + expect(root.innerHTML).toBe('
') + expect(ref.current).toBeInstanceOf(HTMLElement) + }) + }) + + describe('useImperativeHandle()', () => { + it('simple', async () => { + const ref: { current: { focus: () => void } | null } = createRef() + const SubApp = () => { + useImperativeHandle( + ref, + () => ({ + focus: () => { + console.log('focus') + }, + }), + [] + ) + return
+ } + const App = () => { + const [show, setShow] = useState(true) + return ( + <> + {show && } + + + ) + } + render(, root) + expect(ref.current).toBe(null) + await new Promise((r) => setTimeout(r)) + expect(ref.current).toEqual({ focus: expect.any(Function) }) + root.querySelector('button')?.click() + await new Promise((r) => setTimeout(r)) + expect(ref.current).toBe(null) + }) + }) + + describe('useSyncExternalStore()', () => { + it('simple', async () => { + let count = 0 + const unsubscribe = vi.fn() + const subscribe = vi.fn(() => unsubscribe) + const getSnapshot = vi.fn(() => count++) + const SubApp = () => { + const count = useSyncExternalStore(subscribe, getSnapshot) + return
{count}
+ } + const App = () => { + const [show, setShow] = useState(true) + return ( + <> + {show && } + + + ) + } + render(, root) + expect(root.innerHTML).toBe('
0
') + await new Promise((r) => setTimeout(r)) + root.querySelector('button')?.click() + await new Promise((r) => setTimeout(r)) + expect(root.innerHTML).toBe('') + expect(unsubscribe).toBeCalled() + root.querySelector('button')?.click() + await new Promise((r) => setTimeout(r)) + expect(root.innerHTML).toBe('
1
') + }) + }) }) diff --git a/src/jsx/hooks/index.ts b/src/jsx/hooks/index.ts index 454406310..060c4d8e6 100644 --- a/src/jsx/hooks/index.ts +++ b/src/jsx/hooks/index.ts @@ -166,7 +166,7 @@ export const useState: UseStateType = ( newState = (newState as (currentState: T) => T)(stateData[0]) } - if (newState !== stateData[0]) { + if (!Object.is(newState, stateData[0])) { stateData[0] = newState if (pendingStack.length) { const pendingType = pendingStack.at(-1) as PendingType @@ -361,3 +361,52 @@ export const useId = (): string => useMemo(() => `:r${(idCounter++).toString(32) // Define to avoid errors. This hook currently does nothing. // eslint-disable-next-line @typescript-eslint/no-unused-vars export const useDebugValue = (_value: unknown, _formatter?: (value: unknown) => string): void => {} + +export const createRef = (): RefObject => { + return { current: null } +} + +export const forwardRef = ( + Component: (props: P, ref: RefObject) => JSX.Element +): ((props: P & { ref: RefObject }) => JSX.Element) => { + return (props) => { + const { ref, ...rest } = props + return Component(rest as P, ref) + } +} + +export const useImperativeHandle = ( + ref: RefObject, + createHandle: () => T, + deps: readonly unknown[] +): void => { + useEffect(() => { + ref.current = createHandle() + return () => { + ref.current = null + } + }, deps) +} + +let useSyncExternalStoreGetServerSnapshotNotified = false +export const useSyncExternalStore = ( + subscribe: (callback: (value: T) => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T +): T => { + const [state, setState] = useState(getSnapshot()) + useEffect( + () => + subscribe(() => { + setState(getSnapshot()) + }), + [] + ) + + if (getServerSnapshot && !useSyncExternalStoreGetServerSnapshotNotified) { + useSyncExternalStoreGetServerSnapshotNotified = true + console.info('`getServerSnapshot` is not supported yet.') + } + + return state +} diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index acc404d3c..249550d4b 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -385,6 +385,10 @@ describe('render to string', () => { const template =

Hello

expect(template.toString()).toBe('

Hello

') }) + it('should render variable without any name conversion', () => { + const template =

Hello

+ expect(template.toString()).toBe('

Hello

') + }) }) describe('HtmlEscaped in props', () => { @@ -742,6 +746,10 @@ describe('default export', () => { 'useCallback', 'useReducer', 'useDebugValue', + 'createRef', + 'forwardRef', + 'useImperativeHandle', + 'useSyncExternalStore', 'use', 'startTransition', 'useTransition', diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 5d286b7e8..ab85197ae 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -1,4 +1,6 @@ import { jsx, memo, Fragment, isValidElement, cloneElement } from './base' +import type { DOMAttributes } from './base' +import { Children } from './children' import { ErrorBoundary } from './components' import { createContext, useContext } from './context' import { @@ -17,6 +19,10 @@ import { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, } from './hooks' import { Suspense } from './streaming' @@ -45,7 +51,13 @@ export { useViewTransition, useMemo, useLayoutEffect, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, + Children, + DOMAttributes, } export default { @@ -72,7 +84,12 @@ export default { useViewTransition, useMemo, useLayoutEffect, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, + Children, } // TODO: change to `export type *` after denoify bug is fixed diff --git a/src/jsx/jsx-dev-runtime.ts b/src/jsx/jsx-dev-runtime.ts index 0b4a36b0b..7692ca7e0 100644 --- a/src/jsx/jsx-dev-runtime.ts +++ b/src/jsx/jsx-dev-runtime.ts @@ -13,7 +13,6 @@ export function jsxDEV( node = jsxFn(tag, props, []) } else { const children = props.children as string | HtmlEscapedString - delete props['children'] node = Array.isArray(children) ? jsxFn(tag, props, children) : jsxFn(tag, props, [children]) } node.key = key diff --git a/src/jsx/types.ts b/src/jsx/types.ts index 0dd5b129d..85d2c2b9d 100644 --- a/src/jsx/types.ts +++ b/src/jsx/types.ts @@ -1,7 +1,7 @@ /** * All types exported from "hono/jsx" are in this file. */ -import type { Child } from './base' +import type { Child, JSXNode } from './base' export type { Child, JSXNode, FC } from './base' export type { RefObject } from './hooks' @@ -9,3 +9,32 @@ export type { Context } from './context' export type PropsWithChildren

= P & { children?: Child | undefined } export type CSSProperties = Hono.CSSProperties + +/** + * React types + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ReactElement

= JSXNode & { + type: T + props: P + key: string | null +} +type ReactNode = ReactElement | string | number | boolean | null | undefined +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type ComponentClass

= unknown + +export type { ReactElement, ReactNode, ComponentClass } + +export type Event = globalThis.Event +export type MouseEvent = globalThis.MouseEvent +export type KeyboardEvent = globalThis.KeyboardEvent +export type FocusEvent = globalThis.FocusEvent +export type ClipboardEvent = globalThis.ClipboardEvent +export type InputEvent = globalThis.InputEvent +export type PointerEvent = globalThis.PointerEvent +export type TouchEvent = globalThis.TouchEvent +export type WheelEvent = globalThis.WheelEvent +export type AnimationEvent = globalThis.AnimationEvent +export type TransitionEvent = globalThis.TransitionEvent +export type DragEvent = globalThis.DragEvent diff --git a/src/jsx/utils.ts b/src/jsx/utils.ts index bb04288a8..dc8926ace 100644 --- a/src/jsx/utils.ts +++ b/src/jsx/utils.ts @@ -4,3 +4,17 @@ export const normalizeIntrinsicElementProps = (props: Record): delete props['className'] } } + +export const styleObjectForEach = ( + style: Record, + fn: (key: string, value: string | null) => void +): void => { + for (const [k, v] of Object.entries(style)) { + fn( + k[0] === '-' + ? k // CSS variable + : k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`), // style property. convert to kebab-case + v == null ? null : typeof v === 'number' ? v + 'px' : (v as string) + ) + } +}