From 8887fcd839e931ce3bfae84e29a879b8ffb7b7fc Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 16 Apr 2024 05:31:32 +0900 Subject: [PATCH 01/24] feat(jsx/dom): Match the type with the object returned from React's createElement https://react.dev/reference/react/createElement#returns --- src/jsx/base.ts | 10 ++++++++++ src/jsx/dom/index.ts | 2 +- src/jsx/dom/jsx-dev-runtime.ts | 33 ++++++++++++++++++++++++--------- src/jsx/dom/render.ts | 6 ++++-- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/jsx/base.ts b/src/jsx/base.ts index e9dc9719e..513215604 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -114,6 +114,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]) => { diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 8b202a437..b5fdd726f 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -20,7 +20,7 @@ import { } from '../hooks' import { Suspense, ErrorBoundary } from './components' import { createContext } from './context' -import { jsx } from './jsx-runtime' +import { jsx, Fragment } from './jsx-runtime' export { render } from './render' diff --git a/src/jsx/dom/jsx-dev-runtime.ts b/src/jsx/dom/jsx-dev-runtime.ts index fa5141a65..b91a0c96c 100644 --- a/src/jsx/dom/jsx-dev-runtime.ts +++ b/src/jsx/dom/jsx-dev-runtime.ts @@ -1,23 +1,38 @@ -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, + children: Array.isArray(children) ? children : [children], + }, + 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..9596003e8 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -17,6 +17,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 @@ -101,7 +103,7 @@ 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') { @@ -166,7 +168,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]) From 29a8fb3058aacf3d09bb4b011cfc360932632039 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 16 Apr 2024 06:20:02 +0900 Subject: [PATCH 02/24] feat(jsx): add Children utility functions --- src/jsx/children.ts | 19 +++++++++++++++++++ src/jsx/dom/index.ts | 3 +++ src/jsx/index.ts | 3 +++ 3 files changed, 25 insertions(+) create mode 100644 src/jsx/children.ts diff --git a/src/jsx/children.ts b/src/jsx/children.ts new file mode 100644 index 000000000..f1af75a7f --- /dev/null +++ b/src/jsx/children.ts @@ -0,0 +1,19 @@ +import type { Child } from './base' + +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/dom/index.ts b/src/jsx/dom/index.ts index b5fdd726f..3d4be3469 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 { memo, isValidElement } from '../base' +import { Children } from '../children' import { useContext } from '../context' import { useState, @@ -80,6 +81,7 @@ export { isValidElement, createElement, cloneElement, + Children, } export default { @@ -106,6 +108,7 @@ export default { isValidElement, createElement, cloneElement, + Children, } export type { Context } from '../context' diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 5d286b7e8..8d818f2a0 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -1,4 +1,5 @@ import { jsx, memo, Fragment, isValidElement, cloneElement } from './base' +import { Children } from './children' import { ErrorBoundary } from './components' import { createContext, useContext } from './context' import { @@ -46,6 +47,7 @@ export { useMemo, useLayoutEffect, Suspense, + Children, } export default { @@ -73,6 +75,7 @@ export default { useMemo, useLayoutEffect, Suspense, + Children, } // TODO: change to `export type *` after denoify bug is fixed From 869cbc88244bbac8efd6a2203be4998e610bf512 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 16 Apr 2024 06:20:51 +0900 Subject: [PATCH 03/24] feat(jsx/dom): export Fragment from hono/jsx/dom --- src/jsx/dom/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 3d4be3469..a28b0f5c9 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -82,6 +82,7 @@ export { createElement, cloneElement, Children, + Fragment, } export default { @@ -109,6 +110,7 @@ export default { createElement, cloneElement, Children, + Fragment, } export type { Context } from '../context' From 61c0a5bcbea053db3928ffe59ca0e2a8379a74c6 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 16 Apr 2024 06:27:31 +0900 Subject: [PATCH 04/24] feat(jsx): export DOMAttributes type --- src/jsx/base.ts | 1 + src/jsx/dom/index.ts | 3 ++- src/jsx/index.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/jsx/base.ts b/src/jsx/base.ts index 513215604..a176833a1 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -9,6 +9,7 @@ import { normalizeIntrinsicElementProps } from './utils' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Props = Record export type FC = (props: T) => HtmlEscapedString | Promise +export type DOMAttributes = Hono.HTMLAttributes declare global { // eslint-disable-next-line @typescript-eslint/no-namespace diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index a28b0f5c9..eb786b534 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -1,4 +1,4 @@ -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' @@ -83,6 +83,7 @@ export { cloneElement, Children, Fragment, + DOMAttributes, } export default { diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 8d818f2a0..3637be9de 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -1,4 +1,5 @@ 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' @@ -48,6 +49,7 @@ export { useLayoutEffect, Suspense, Children, + DOMAttributes, } export default { From e618c228a106cdeaa7832ecb1cb75685379c8809 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 16 Apr 2024 07:46:18 +0900 Subject: [PATCH 05/24] feat(jsx): export ReactNode and ReactElement types --- src/jsx/dom/index.ts | 4 ++++ src/jsx/types.ts | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index eb786b534..78b50d3b8 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -115,3 +115,7 @@ export default { } 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/types.ts b/src/jsx/types.ts index 0dd5b129d..8ebf0b828 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,17 @@ 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 + +export type { ReactElement, ReactNode } From d1ba481e037a97f08d4dda84b64b3f14ceba80d0 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 16 Apr 2024 09:08:00 +0900 Subject: [PATCH 06/24] feat(jsx): export types for compat with react --- src/jsx/types.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/jsx/types.ts b/src/jsx/types.ts index 8ebf0b828..85d2c2b9d 100644 --- a/src/jsx/types.ts +++ b/src/jsx/types.ts @@ -21,5 +21,20 @@ type ReactElement

= JSXNode & { 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 } +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 From e862c7a9165fe79e3b741d14837129770e7a8ff3 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 17 Apr 2024 06:17:14 +0900 Subject: [PATCH 07/24] feat(jsx): Add hooks and utils for React compatibility * createRef * forwardRef * useImperativeHandle * useSyncExternalStore --- src/jsx/dom/index.ts | 12 +++++++++++ src/jsx/hooks/index.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ src/jsx/index.ts | 12 +++++++++++ 3 files changed, 73 insertions(+) diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 78b50d3b8..ec4373682 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -18,6 +18,10 @@ import { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, } from '../hooks' import { Suspense, ErrorBoundary } from './components' import { createContext } from './context' @@ -73,6 +77,10 @@ export { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, ErrorBoundary, createContext, @@ -102,6 +110,10 @@ export default { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, ErrorBoundary, createContext, diff --git a/src/jsx/hooks/index.ts b/src/jsx/hooks/index.ts index 454406310..bff359cea 100644 --- a/src/jsx/hooks/index.ts +++ b/src/jsx/hooks/index.ts @@ -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.ts b/src/jsx/index.ts index 3637be9de..ab85197ae 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -19,6 +19,10 @@ import { useReducer, useId, useDebugValue, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, } from './hooks' import { Suspense } from './streaming' @@ -47,6 +51,10 @@ export { useViewTransition, useMemo, useLayoutEffect, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, Children, DOMAttributes, @@ -76,6 +84,10 @@ export default { useViewTransition, useMemo, useLayoutEffect, + createRef, + forwardRef, + useImperativeHandle, + useSyncExternalStore, Suspense, Children, } From 1023dc115ff07d6b697843cda2396a078387d6ec Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 17 Apr 2024 06:18:39 +0900 Subject: [PATCH 08/24] refactor(jsx): use Object.is for state comparison like React > If the new value you provide is identical to the current state, as determined by an Object.is comparison, React will skip re-rendering the component and its children. https://react.dev/reference/react/useState#setstate-caveats --- src/jsx/hooks/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsx/hooks/index.ts b/src/jsx/hooks/index.ts index bff359cea..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 From 7f4833a6e722e9ee96063710cc5c23ddd1d5cfff Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:31:15 +0900 Subject: [PATCH 09/24] feat(jsx/dom): Implement `flushSync` and `createPortal` --- src/jsx/dom/index.ts | 5 +++++ src/jsx/dom/render.ts | 47 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index ec4373682..a2be33fce 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -26,6 +26,7 @@ import { import { Suspense, ErrorBoundary } from './components' import { createContext } from './context' import { jsx, Fragment } from './jsx-runtime' +import { flushSync, createPortal } from './render' export { render } from './render' @@ -92,6 +93,8 @@ export { Children, Fragment, DOMAttributes, + flushSync, + createPortal, } export default { @@ -124,6 +127,8 @@ export default { cloneElement, Children, Fragment, + flushSync, + createPortal, } export type { Context } from '../context' diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 9596003e8..e321edc3e 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -7,6 +7,8 @@ import type { EffectData } from '../hooks' import { STASH_EFFECT } from '../hooks' import { createContext } from './context' // import dom-specific versions +const HONO_PORTAL_ELEMENT = '_hp' + const eventAliasMap: Record = { Change: 'Input', DoubleClick: 'DblClick', @@ -325,7 +327,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) } } @@ -481,6 +487,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 @@ -507,12 +514,16 @@ 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 @@ -527,3 +538,27 @@ 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 => { + const node = buildNode({ + tag: HONO_PORTAL_ELEMENT, + children: [children], + key, + } as JSXNode) as NodeObject + node.e = container + return node as Child +} From e8b9e0f250d49dbacde4d3ca1c124ba022fcefb1 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:33:52 +0900 Subject: [PATCH 10/24] feat(jsx/dom/jsx-dev-runtime): preserve only one children in jsxDEV --- src/jsx/dom/index.ts | 6 +++++- src/jsx/dom/jsx-dev-runtime.ts | 2 +- src/jsx/dom/render.ts | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index a2be33fce..4143251a4 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -35,7 +35,11 @@ const createElement = ( props: Props, ...children: Child[] ): JSXNode => { - const jsxProps: Props = { ...props, children } + const jsxProps: Props = { ...props } + if (children.length) { + jsxProps.children = children.length === 1 ? children[0] : children + } + let key = undefined if ('key' in jsxProps) { key = jsxProps.key diff --git a/src/jsx/dom/jsx-dev-runtime.ts b/src/jsx/dom/jsx-dev-runtime.ts index b91a0c96c..853fdf431 100644 --- a/src/jsx/dom/jsx-dev-runtime.ts +++ b/src/jsx/dom/jsx-dev-runtime.ts @@ -29,7 +29,7 @@ export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXN tag, props, key, - children: Array.isArray(children) ? children : [children], + children, }, JSXNodeCompatPrototype ) as JSXNode diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index e321edc3e..78f583e9f 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -353,7 +353,12 @@ export const build = ( } let errorHandler: ErrorHandler | undefined - children ||= typeof node.tag == 'function' ? invokeTag(context, node) : node.children + children ||= + typeof node.tag == 'function' + ? invokeTag(context, node) + : Array.isArray(node.children) + ? node.children + : [node.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 From 5e19c547df1948ccea81fb1bd72393e9e59c792e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:37:04 +0900 Subject: [PATCH 11/24] refactor(jsx/dom): change data structure of NodeString --- src/jsx/dom/render.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 78f583e9f..3f0d96e28 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -52,10 +52,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 @@ -91,7 +91,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)?$/) @@ -275,7 +275,7 @@ const apply = (node: NodeObject, container: Container) => { const applyNode = (node: Node, container: Container) => { if (isNodeString(node)) { - container.textContent = node[0] + container.textContent = node.t } else { applyNodeObject(node, container) } @@ -315,11 +315,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) @@ -393,9 +393,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 } @@ -445,7 +445,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, []] From 58684a98f04811a077a3bf234abfa3d5fb467fdf Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:38:08 +0900 Subject: [PATCH 12/24] feat(jsx/dom): accept nullish value for event handler and ref --- src/jsx/dom/render.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 3f0d96e28..49269ff52 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -108,20 +108,21 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute 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') { From 0fa140684321edd6981fa3086335baed67b048b6 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:42:06 +0900 Subject: [PATCH 13/24] feat(jsx/dom): enable to set CSS variables --- src/jsx/dom/render.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 49269ff52..514e87e1e 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -129,8 +129,18 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute if (typeof value === 'string') { container.style.cssText = value } else { - container.style.cssText = '' - Object.assign(container.style, value) + const style = container.style + style.cssText = '' + if (value != null) { + for (const [k, v] of Object.entries(value)) { + style.setProperty( + 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) + ) + } + } } } else { const nodeName = container.nodeName From 343b2638f64d833a1d880f29358323631f241757 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:42:52 +0900 Subject: [PATCH 14/24] feat(jsx/dom): support defaultProps in functional components --- src/jsx/base.ts | 6 +++++- src/jsx/dom/render.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/jsx/base.ts b/src/jsx/base.ts index a176833a1..faa6f67bd 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -8,7 +8,11 @@ import { normalizeIntrinsicElementProps } 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 { diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 514e87e1e..33fe57b12 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -212,6 +212,8 @@ 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, }), From df1099787c5a263bbfc0a086c98e3bff8107eeb2 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:43:36 +0900 Subject: [PATCH 15/24] refactor(jsx/dom): improve removing node process --- src/jsx/dom/render.ts | 46 ++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 33fe57b12..733c87136 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -271,17 +271,26 @@ 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) } @@ -355,16 +364,16 @@ 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' @@ -438,9 +447,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 From 0c8ea120b9b6c652cc72326fc1ed430207cf8f45 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:44:49 +0900 Subject: [PATCH 16/24] fix(jsx/dom): propagate the context to the children --- src/jsx/dom/render.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 733c87136..abe3bf661 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -427,6 +427,9 @@ export const build = ( 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) { From dab279255a04f1e240a6c18438671735b4f22fab Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:46:31 +0900 Subject: [PATCH 17/24] perf(jsx/dom): skip snapshotting for internal tags --- src/jsx/constants.ts | 1 + src/jsx/dom/context.ts | 36 +++++++++++++++++++++++------------- src/jsx/dom/render.ts | 9 +++++++-- src/jsx/dom/utils.ts | 7 +++++++ 4 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 src/jsx/dom/utils.ts 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.ts b/src/jsx/dom/context.ts index 6b4a3adbd..d57300885 100644 --- a/src/jsx/dom/context.ts +++ b/src/jsx/dom/context.ts @@ -3,32 +3,42 @@ 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() - }, + }), }, ], + } + if (Array.isArray(children)) { + props.children.push(...children.flat()) + } else { + props.children.push(children) + } + props.children.push({ + tag: setInternalTagFlag(() => { + values.pop() + }), }) + 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/render.ts b/src/jsx/dom/render.ts index abe3bf661..be711aff8 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -1,6 +1,6 @@ import type { JSXNode } from '../base' import type { FC, Child, Props } from '../base' -import { DOM_RENDERER, DOM_ERROR_HANDLER, DOM_STASH } from '../constants' +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' @@ -399,7 +399,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)]) } 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 +} From 85e6f462b08f5e93b022a4136acf93aa97769dad Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 25 Apr 2024 05:25:06 +0900 Subject: [PATCH 18/24] feat(jsx): preserve only one children also in hono/jsx/jsx-dev-runtime.ts --- src/jsx/base.ts | 12 ++++++++++-- src/jsx/jsx-dev-runtime.ts | 1 - 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/jsx/base.ts b/src/jsx/base.ts index faa6f67bd..c47b3a69d 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -156,8 +156,11 @@ 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') { + if (key === 'children') { + // skip children + } + else if (key === 'style' && typeof v === 'object') { + // object to style strings const styles = Object.keys(v) .map((k) => { const property = k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) @@ -259,6 +262,11 @@ export const jsx = ( props: Props, ...children: (string | HtmlEscapedString)[] ): JSXNode => { + if (children.length) { + props ??= {} + props.children = children.length === 1 ? children[0] : children + } + let key if (props) { key = props?.key 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 From 6fb5a9ab48356c16f17b9fbc1a805a867acc5cd3 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 25 Apr 2024 05:44:30 +0900 Subject: [PATCH 19/24] feat(jsx): support CSS variable in server-side rendering --- src/jsx/base.ts | 19 +++++++++---------- src/jsx/dom/render.ts | 14 ++++---------- src/jsx/utils.ts | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/jsx/base.ts b/src/jsx/base.ts index c47b3a69d..b6d45f53e 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -4,7 +4,7 @@ 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 @@ -158,17 +158,16 @@ export class JSXNode implements HtmlEscaped { const v = props[key] if (key === 'children') { // skip children - } - else if (key === 'style' && typeof v === 'object') { + } else if (key === 'style' && typeof v === 'object') { // object to style strings - const styles = Object.keys(v) - .map((k) => { - const property = k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) - return `${property}:${v[k]}` - }) - .join(';') + 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}="` diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index be711aff8..30ac1056d 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -5,6 +5,7 @@ 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' @@ -126,20 +127,13 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute value.current = container } } else if (key === 'style') { + const style = container.style if (typeof value === 'string') { - container.style.cssText = value + style.cssText = value } else { - const style = container.style style.cssText = '' if (value != null) { - for (const [k, v] of Object.entries(value)) { - style.setProperty( - 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) - ) - } + styleObjectForEach(value, style.setProperty.bind(style)) } } } else { 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) + ) + } +} From be6d2b91c3a218ca22c60b41b81192a684d4933f Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 25 Apr 2024 07:54:43 +0900 Subject: [PATCH 20/24] feat(jsx): accept null as props in createElement --- deno_dist/jsx/base.ts | 2 +- src/jsx/base.ts | 16 +++++++--------- src/jsx/dom/index.ts | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/deno_dist/jsx/base.ts b/deno_dist/jsx/base.ts index fdc8dc0e5..056333bd7 100644 --- a/deno_dist/jsx/base.ts +++ b/deno_dist/jsx/base.ts @@ -257,7 +257,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) diff --git a/src/jsx/base.ts b/src/jsx/base.ts index b6d45f53e..66c70eede 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -258,19 +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 => { + props ??= {} if (children.length) { - props ??= {} props.children = children.length === 1 ? children[0] : children } - let key - if (props) { - key = props?.key - delete props['key'] - } + const key = props.key + delete props['key'] + const node = jsxFn(tag, props, children) node.key = key return node @@ -279,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) diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 4143251a4..085ece8d8 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -32,10 +32,10 @@ export { render } from './render' const createElement = ( tag: string | ((props: Props) => JSXNode), - props: Props, + props: Props | null, ...children: Child[] ): JSXNode => { - const jsxProps: Props = { ...props } + const jsxProps: Props = props ? { ...props } : {} if (children.length) { jsxProps.children = children.length === 1 ? children[0] : children } From b9b080ddf235115b109126de934a7364924d32dd Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 25 Apr 2024 22:57:28 +0900 Subject: [PATCH 21/24] refactor(jsx): refactor internal children handling --- src/jsx/base.ts | 17 ++++++----------- src/jsx/dom/context.ts | 2 ++ src/jsx/dom/css.ts | 12 ++++++++---- src/jsx/dom/index.ts | 2 +- src/jsx/dom/jsx-dev-runtime.ts | 7 ------- src/jsx/dom/render.ts | 31 ++++++++++++++++--------------- 6 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/jsx/base.ts b/src/jsx/base.ts index 66c70eede..15e1477d0 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -337,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 = ( @@ -357,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/dom/context.ts b/src/jsx/dom/context.ts index d57300885..c1a3d8b33 100644 --- a/src/jsx/dom/context.ts +++ b/src/jsx/dom/context.ts @@ -18,6 +18,7 @@ export const createContextProviderFunction = (values: T[]) => tag: setInternalTagFlag(() => { values.push(value) }), + props: {}, }, ], } @@ -30,6 +31,7 @@ export const createContextProviderFunction = (values: T[]) => tag: setInternalTagFlag(() => { values.pop() }), + props: {}, }) const res = Fragment(props) // eslint-disable-next-line @typescript-eslint/no-explicit-any 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.ts b/src/jsx/dom/index.ts index 085ece8d8..3612119da 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -59,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 diff --git a/src/jsx/dom/jsx-dev-runtime.ts b/src/jsx/dom/jsx-dev-runtime.ts index 853fdf431..28b9acdc9 100644 --- a/src/jsx/dom/jsx-dev-runtime.ts +++ b/src/jsx/dom/jsx-dev-runtime.ts @@ -18,18 +18,11 @@ export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXN if (typeof tag === 'string') { normalizeIntrinsicElementProps(props) } - let children - if (props && 'children' in props) { - children = props.children - } else { - children = [] - } return Object.defineProperties( { tag, props, key, - children, }, JSXNodeCompatPrototype ) as JSXNode diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 30ac1056d..64f1aad88 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -209,7 +209,6 @@ const invokeTag = (context: Context, node: NodeObject): Child[] => { // eslint-disable-next-line @typescript-eslint/no-explicit-any ...((func as any).defaultProps || {}), ...node.props, - children: node.children, }), ] } finally { @@ -369,12 +368,13 @@ export const build = ( children?: Child[] ): void => { let errorHandler: ErrorHandler | undefined + const nodeChildren = node.props.children children ||= typeof node.tag == 'function' ? invokeTag(context, node) - : Array.isArray(node.children) - ? node.children - : [node.children] + : Array.isArray(nodeChildren) + ? nodeChildren + : [nodeChildren] 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 @@ -425,7 +425,6 @@ 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] || [] } @@ -491,13 +490,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 @@ -572,7 +571,8 @@ export const update = async ( } 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() @@ -595,12 +595,13 @@ export const flushSync = (callback: () => void) => { currentUpdateSets.pop() } -export const createPortal = (children: Child, container: HTMLElement, key?: string): Child => { - const node = buildNode({ +export const createPortal = (children: Child, container: HTMLElement, key?: string): Child => + ({ tag: HONO_PORTAL_ELEMENT, - children: [children], + props: { + children, + }, key, - } as JSXNode) as NodeObject - node.e = container - return node as Child -} + e: container, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) From c1000422dfe6c86bfbfa095e787746905490d3c6 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 24 Apr 2024 20:49:17 +0900 Subject: [PATCH 22/24] chore: denoify --- deno_dist/jsx/base.ts | 71 ++++++----- deno_dist/jsx/children.ts | 19 +++ deno_dist/jsx/constants.ts | 1 + deno_dist/jsx/dom/context.ts | 38 ++++-- deno_dist/jsx/dom/css.ts | 12 +- deno_dist/jsx/dom/index.ts | 41 +++++- deno_dist/jsx/dom/jsx-dev-runtime.ts | 38 +++--- deno_dist/jsx/dom/render.ts | 182 +++++++++++++++++++-------- deno_dist/jsx/dom/utils.ts | 7 ++ deno_dist/jsx/hooks/index.ts | 51 +++++++- deno_dist/jsx/index.ts | 17 +++ deno_dist/jsx/jsx-dev-runtime.ts | 1 - deno_dist/jsx/types.ts | 31 ++++- deno_dist/jsx/utils.ts | 14 +++ 14 files changed, 404 insertions(+), 119 deletions(-) create mode 100644 deno_dist/jsx/children.ts create mode 100644 deno_dist/jsx/dom/utils.ts diff --git a/deno_dist/jsx/base.ts b/deno_dist/jsx/base.ts index 056333bd7..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 @@ -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..1125fe70f --- /dev/null +++ b/deno_dist/jsx/children.ts @@ -0,0 +1,19 @@ +import type { Child } from './base.ts' + +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..d8ca5d193 100644 --- a/deno_dist/jsx/dom/render.ts +++ b/deno_dist/jsx/dom/render.ts @@ -1,12 +1,15 @@ 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 { 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 +20,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 +53,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 +92,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 +106,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 +175,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 +206,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 +264,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 +330,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 +342,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 +357,24 @@ 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 + const nodeChildren = node.props.children + children ||= + typeof node.tag == 'function' + ? invokeTag(context, node) + : Array.isArray(nodeChildren) + ? nodeChildren + : [nodeChildren] 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 +393,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 +414,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 +425,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 +448,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 +481,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 +490,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 +528,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 +555,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 +580,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) + ) + } +} From 4215e02bcd69f401d6d17b423ad16fe52dc46185 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 25 Apr 2024 06:47:17 +0900 Subject: [PATCH 23/24] test(jsx): add tests --- src/jsx/children.test.ts | 52 ++++++++ src/jsx/dom/context.test.tsx | 31 ++++- src/jsx/dom/index.test.tsx | 232 ++++++++++++++++++++++++++++++++++- src/jsx/hooks/dom.test.tsx | 94 ++++++++++++++ src/jsx/index.test.tsx | 8 ++ 5 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 src/jsx/children.test.ts 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/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/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/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/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', From 12e4b8e9c83471a0b02700bc598d30996754d747 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Fri, 26 Apr 2024 08:01:36 +0900 Subject: [PATCH 24/24] refactor(jsx): use `toArray` unitility function in render.ts --- deno_dist/jsx/children.ts | 3 ++- deno_dist/jsx/dom/render.ts | 8 ++------ src/jsx/children.ts | 3 ++- src/jsx/dom/render.ts | 8 ++------ 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/deno_dist/jsx/children.ts b/deno_dist/jsx/children.ts index 1125fe70f..377bdeb3c 100644 --- a/deno_dist/jsx/children.ts +++ b/deno_dist/jsx/children.ts @@ -1,6 +1,7 @@ import type { Child } from './base.ts' -const toArray = (children: Child): Child[] => (Array.isArray(children) ? children : [children]) +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), diff --git a/deno_dist/jsx/dom/render.ts b/deno_dist/jsx/dom/render.ts index d8ca5d193..23dfd936b 100644 --- a/deno_dist/jsx/dom/render.ts +++ b/deno_dist/jsx/dom/render.ts @@ -1,5 +1,6 @@ import type { JSXNode } from '../base.ts' import type { FC, Child, Props } from '../base.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' @@ -368,13 +369,8 @@ export const build = ( children?: Child[] ): void => { let errorHandler: ErrorHandler | undefined - const nodeChildren = node.props.children children ||= - typeof node.tag == 'function' - ? invokeTag(context, node) - : Array.isArray(nodeChildren) - ? nodeChildren - : [nodeChildren] + 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 diff --git a/src/jsx/children.ts b/src/jsx/children.ts index f1af75a7f..eeb954a8c 100644 --- a/src/jsx/children.ts +++ b/src/jsx/children.ts @@ -1,6 +1,7 @@ import type { Child } from './base' -const toArray = (children: Child): Child[] => (Array.isArray(children) ? children : [children]) +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), diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 64f1aad88..dcc0f3cc3 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -1,5 +1,6 @@ import type { JSXNode } from '../base' import type { FC, Child, Props } from '../base' +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' @@ -368,13 +369,8 @@ export const build = ( children?: Child[] ): void => { let errorHandler: ErrorHandler | undefined - const nodeChildren = node.props.children children ||= - typeof node.tag == 'function' - ? invokeTag(context, node) - : Array.isArray(nodeChildren) - ? nodeChildren - : [nodeChildren] + 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