From 999c0153730e26d5146aaa4bfbfd917e402da28e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 5 Feb 2024 05:58:13 +0900 Subject: [PATCH 1/4] refactor(jsx): export types from src/jsx/types.ts --- src/jsx/base.ts | 331 +++++++++++++++++++++++++++++++++++++++++++++ src/jsx/index.ts | 339 +---------------------------------------------- src/jsx/types.ts | 11 ++ 3 files changed, 344 insertions(+), 337 deletions(-) create mode 100644 src/jsx/base.ts create mode 100644 src/jsx/types.ts diff --git a/src/jsx/base.ts b/src/jsx/base.ts new file mode 100644 index 000000000..a27d543c1 --- /dev/null +++ b/src/jsx/base.ts @@ -0,0 +1,331 @@ +import { raw } from '../helper/html' +import { escapeToBuffer, stringBufferToString } from '../utils/html' +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' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Props = Record +export type FC = (props: T) => HtmlEscapedString | Promise + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + type Element = HtmlEscapedString | Promise + interface ElementChildrenAttribute { + children: Child + } + interface IntrinsicElements extends IntrinsicElementsDefined { + [tagName: string]: Props + } + } +} + +const emptyTags = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +] +const booleanAttributes = [ + 'allowfullscreen', + 'async', + 'autofocus', + 'autoplay', + 'checked', + 'controls', + 'default', + 'defer', + 'disabled', + 'formnovalidate', + 'hidden', + 'inert', + 'ismap', + 'itemscope', + 'loop', + 'multiple', + 'muted', + 'nomodule', + 'novalidate', + 'open', + 'playsinline', + 'readonly', + 'required', + 'reversed', + 'selected', +] + +const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void => { + for (let i = 0, len = children.length; i < len; i++) { + const child = children[i] + if (typeof child === 'string') { + escapeToBuffer(child, buffer) + } else if (typeof child === 'boolean' || child === null || child === undefined) { + continue + } else if (child instanceof JSXNode) { + child.toStringToBuffer(buffer) + } else if ( + typeof child === 'number' || + (child as unknown as { isEscaped: boolean }).isEscaped + ) { + ;(buffer[0] as string) += child + } else if (child instanceof Promise) { + buffer.unshift('', child) + } else { + // `child` type is `Child[]`, so stringify recursively + childrenToStringToBuffer(child, buffer) + } + } +} + +type LocalContexts = [Context, unknown][] +export type Child = string | Promise | number | JSXNode | Child[] +export class JSXNode implements HtmlEscaped { + tag: string | Function + props: Props + key?: string + children: Child[] + isEscaped: true = true as const + localContexts?: LocalContexts + constructor(tag: string | Function, props: Props, children: Child[]) { + this.tag = tag + this.props = props + this.children = children + } + + toString(): string | Promise { + const buffer: StringBuffer = [''] + this.localContexts?.forEach(([context, value]) => { + context.values.push(value) + }) + try { + this.toStringToBuffer(buffer) + } finally { + this.localContexts?.forEach(([context]) => { + context.values.pop() + }) + } + return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) + } + + toStringToBuffer(buffer: StringBuffer): void { + const tag = this.tag as string + const props = this.props + let { children } = this + + buffer[0] += `<${tag}` + + const propsKeys = Object.keys(props || {}) + + 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(';') + buffer[0] += ` style="${styles}"` + } else if (typeof v === 'string') { + buffer[0] += ` ${key}="` + escapeToBuffer(v, buffer) + buffer[0] += '"' + } else if (v === null || v === undefined) { + // Do nothing + } else if (typeof v === 'number' || (v as HtmlEscaped).isEscaped) { + buffer[0] += ` ${key}="${v}"` + } else if (typeof v === 'boolean' && booleanAttributes.includes(key)) { + if (v) { + buffer[0] += ` ${key}=""` + } + } else if (key === 'dangerouslySetInnerHTML') { + if (children.length > 0) { + throw 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.' + } + + children = [raw(v.__html)] + } else if (v instanceof Promise) { + buffer[0] += ` ${key}="` + buffer.unshift('"', v) + } else { + buffer[0] += ` ${key}="` + escapeToBuffer(v.toString(), buffer) + buffer[0] += '"' + } + } + + if (emptyTags.includes(tag as string)) { + buffer[0] += '/>' + return + } + + buffer[0] += '>' + + childrenToStringToBuffer(children, buffer) + + buffer[0] += `` + } +} + +class JSXFunctionNode extends JSXNode { + toStringToBuffer(buffer: StringBuffer): void { + const { children } = this + + const res = (this.tag as Function).call(null, { + ...this.props, + children: children.length <= 1 ? children[0] : children, + }) + + if (res instanceof Promise) { + if (globalContexts.length === 0) { + buffer.unshift('', res) + } else { + // save current contexts for resuming + const currentContexts: LocalContexts = globalContexts.map((c) => [c, c.values.at(-1)]) + buffer.unshift( + '', + res.then((childRes) => { + if (childRes instanceof JSXNode) { + childRes.localContexts = currentContexts + } + return childRes + }) + ) + } + } else if (res instanceof JSXNode) { + res.toStringToBuffer(buffer) + } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { + buffer[0] += res + } else { + escapeToBuffer(res, buffer) + } + } +} + +export class JSXFragmentNode extends JSXNode { + toStringToBuffer(buffer: StringBuffer): void { + childrenToStringToBuffer(this.children, buffer) + } +} + +export const jsx = ( + tag: string | Function, + props: Props, + ...children: (string | HtmlEscapedString)[] +): JSXNode => { + let key + if (props) { + key = props?.key + delete props['key'] + } + const node = jsxFn(tag, props, children) + node.key = key + return node +} + +export const jsxFn = ( + tag: string | Function, + props: Props, + children: (string | HtmlEscapedString)[] +): JSXNode => { + if (typeof tag === 'function') { + return new JSXFunctionNode(tag, props, children) + } else { + normalizeIntrinsicElementProps(props) + return new JSXNode(tag, props, children) + } +} + +const shallowEqual = (a: Props, b: Props): boolean => { + if (a === b) { + return true + } + + const aKeys = Object.keys(a).sort() + const bKeys = Object.keys(b).sort() + if (aKeys.length !== bKeys.length) { + return false + } + + for (let i = 0, len = aKeys.length; i < len; i++) { + if ( + aKeys[i] === 'children' && + bKeys[i] === 'children' && + !a.children?.length && + !b.children?.length + ) { + continue + } else if (a[aKeys[i]] !== b[aKeys[i]]) { + return false + } + } + + return true +} + +export const memo = ( + component: FC, + propsAreEqual: (prevProps: Readonly, nextProps: Readonly) => boolean = shallowEqual +): FC => { + let computed: HtmlEscapedString | Promise | undefined = undefined + let prevProps: T | undefined = undefined + return ((props: T & { children?: Child }): HtmlEscapedString | Promise => { + if (prevProps && !propsAreEqual(prevProps, props)) { + computed = undefined + } + prevProps = props + return (computed ||= component(props)) + }) as FC +} + +export const Fragment = ({ + children, +}: { + key?: string + children?: Child | HtmlEscapedString +}): HtmlEscapedString => { + return new JSXFragmentNode( + '', + {}, + 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 + ) +} + +export const cloneElement = ( + element: T, + props: Partial, + ...children: Child[] +): T => { + return jsxFn( + (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) || [] + ) as T +} diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 652bde11d..668fbfbc9 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -1,11 +1,4 @@ -import { raw } from '../helper/html' -import { escapeToBuffer, stringBufferToString } from '../utils/html' -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' - +export { jsx, memo, Fragment, isValidElement, cloneElement } from './base' export { ErrorBoundary } from './components' export { Suspense } from './streaming' export { @@ -22,333 +15,5 @@ export { useMemo, useLayoutEffect, } from './hooks' -export type { RefObject } from './hooks' export { createContext, useContext } from './context' -export type { Context } from './context' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type Props = Record - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - type Element = HtmlEscapedString | Promise - interface ElementChildrenAttribute { - children: Child - } - interface IntrinsicElements extends IntrinsicElementsDefined { - [tagName: string]: Props - } - } -} - -const emptyTags = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'keygen', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', -] -const booleanAttributes = [ - 'allowfullscreen', - 'async', - 'autofocus', - 'autoplay', - 'checked', - 'controls', - 'default', - 'defer', - 'disabled', - 'formnovalidate', - 'hidden', - 'inert', - 'ismap', - 'itemscope', - 'loop', - 'multiple', - 'muted', - 'nomodule', - 'novalidate', - 'open', - 'playsinline', - 'readonly', - 'required', - 'reversed', - 'selected', -] - -const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void => { - for (let i = 0, len = children.length; i < len; i++) { - const child = children[i] - if (typeof child === 'string') { - escapeToBuffer(child, buffer) - } else if (typeof child === 'boolean' || child === null || child === undefined) { - continue - } else if (child instanceof JSXNode) { - child.toStringToBuffer(buffer) - } else if ( - typeof child === 'number' || - (child as unknown as { isEscaped: boolean }).isEscaped - ) { - ;(buffer[0] as string) += child - } else if (child instanceof Promise) { - buffer.unshift('', child) - } else { - // `child` type is `Child[]`, so stringify recursively - childrenToStringToBuffer(child, buffer) - } - } -} - -type LocalContexts = [Context, unknown][] -export type Child = string | Promise | number | JSXNode | Child[] -export class JSXNode implements HtmlEscaped { - tag: string | Function - props: Props - key?: string - children: Child[] - isEscaped: true = true as const - localContexts?: LocalContexts - constructor(tag: string | Function, props: Props, children: Child[]) { - this.tag = tag - this.props = props - this.children = children - } - - toString(): string | Promise { - const buffer: StringBuffer = [''] - this.localContexts?.forEach(([context, value]) => { - context.values.push(value) - }) - try { - this.toStringToBuffer(buffer) - } finally { - this.localContexts?.forEach(([context]) => { - context.values.pop() - }) - } - return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) - } - - toStringToBuffer(buffer: StringBuffer): void { - const tag = this.tag as string - const props = this.props - let { children } = this - - buffer[0] += `<${tag}` - - const propsKeys = Object.keys(props || {}) - - 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(';') - buffer[0] += ` style="${styles}"` - } else if (typeof v === 'string') { - buffer[0] += ` ${key}="` - escapeToBuffer(v, buffer) - buffer[0] += '"' - } else if (v === null || v === undefined) { - // Do nothing - } else if (typeof v === 'number' || (v as HtmlEscaped).isEscaped) { - buffer[0] += ` ${key}="${v}"` - } else if (typeof v === 'boolean' && booleanAttributes.includes(key)) { - if (v) { - buffer[0] += ` ${key}=""` - } - } else if (key === 'dangerouslySetInnerHTML') { - if (children.length > 0) { - throw 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.' - } - - children = [raw(v.__html)] - } else if (v instanceof Promise) { - buffer[0] += ` ${key}="` - buffer.unshift('"', v) - } else { - buffer[0] += ` ${key}="` - escapeToBuffer(v.toString(), buffer) - buffer[0] += '"' - } - } - - if (emptyTags.includes(tag as string)) { - buffer[0] += '/>' - return - } - - buffer[0] += '>' - - childrenToStringToBuffer(children, buffer) - - buffer[0] += `` - } -} - -class JSXFunctionNode extends JSXNode { - toStringToBuffer(buffer: StringBuffer): void { - const { children } = this - - const res = (this.tag as Function).call(null, { - ...this.props, - children: children.length <= 1 ? children[0] : children, - }) - - if (res instanceof Promise) { - if (globalContexts.length === 0) { - buffer.unshift('', res) - } else { - // save current contexts for resuming - const currentContexts: LocalContexts = globalContexts.map((c) => [c, c.values.at(-1)]) - buffer.unshift( - '', - res.then((childRes) => { - if (childRes instanceof JSXNode) { - childRes.localContexts = currentContexts - } - return childRes - }) - ) - } - } else if (res instanceof JSXNode) { - res.toStringToBuffer(buffer) - } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { - buffer[0] += res - } else { - escapeToBuffer(res, buffer) - } - } -} - -export class JSXFragmentNode extends JSXNode { - toStringToBuffer(buffer: StringBuffer): void { - childrenToStringToBuffer(this.children, buffer) - } -} - -export const jsx = ( - tag: string | Function, - props: Props, - ...children: (string | HtmlEscapedString)[] -): JSXNode => { - let key - if (props) { - key = props?.key - delete props['key'] - } - const node = jsxFn(tag, props, children) - node.key = key - return node -} - -export const jsxFn = ( - tag: string | Function, - props: Props, - children: (string | HtmlEscapedString)[] -): JSXNode => { - if (typeof tag === 'function') { - return new JSXFunctionNode(tag, props, children) - } else { - normalizeIntrinsicElementProps(props) - return new JSXNode(tag, props, children) - } -} - -export type FC = ( - props: T & { children?: Child } -) => HtmlEscapedString | Promise - -const shallowEqual = (a: Props, b: Props): boolean => { - if (a === b) { - return true - } - - const aKeys = Object.keys(a).sort() - const bKeys = Object.keys(b).sort() - if (aKeys.length !== bKeys.length) { - return false - } - - for (let i = 0, len = aKeys.length; i < len; i++) { - if ( - aKeys[i] === 'children' && - bKeys[i] === 'children' && - !a.children?.length && - !b.children?.length - ) { - continue - } else if (a[aKeys[i]] !== b[aKeys[i]]) { - return false - } - } - - return true -} - -export const memo = ( - component: FC, - propsAreEqual: (prevProps: Readonly, nextProps: Readonly) => boolean = shallowEqual -): FC => { - let computed: HtmlEscapedString | Promise | undefined = undefined - let prevProps: T | undefined = undefined - return ((props: T & { children?: Child }): HtmlEscapedString | Promise => { - if (prevProps && !propsAreEqual(prevProps, props)) { - computed = undefined - } - prevProps = props - return (computed ||= component(props)) - }) as FC -} - -export const Fragment = ({ - children, -}: { - key?: string - children?: Child | HtmlEscapedString -}): HtmlEscapedString => { - return new JSXFragmentNode( - '', - {}, - 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 - ) -} - -export const cloneElement = ( - element: T, - props: Partial, - ...children: Child[] -): T => { - return jsxFn( - (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) || [] - ) as T -} +export type * from './types' diff --git a/src/jsx/types.ts b/src/jsx/types.ts new file mode 100644 index 000000000..0dd5b129d --- /dev/null +++ b/src/jsx/types.ts @@ -0,0 +1,11 @@ +/** + * All types exported from "hono/jsx" are in this file. + */ +import type { Child } from './base' + +export type { Child, JSXNode, FC } from './base' +export type { RefObject } from './hooks' +export type { Context } from './context' + +export type PropsWithChildren

= P & { children?: Child | undefined } +export type CSSProperties = Hono.CSSProperties From 4fc0bf1d90c60503f5d5170359b7c615de591566 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 5 Feb 2024 05:58:22 +0900 Subject: [PATCH 2/4] refactor(jsx): migrate to new type definition --- src/helper/css/index.test.tsx | 5 +++-- src/jsx/components.test.tsx | 2 +- src/jsx/components.ts | 14 ++++++++------ src/jsx/context.ts | 6 +++--- src/jsx/dom/components.test.tsx | 2 +- src/jsx/dom/components.ts | 19 ++++++++++++------- src/jsx/dom/context.test.tsx | 4 ++-- src/jsx/dom/context.ts | 2 +- src/jsx/dom/css.test.tsx | 2 +- src/jsx/dom/css.ts | 4 ++-- src/jsx/dom/index.test.tsx | 2 +- src/jsx/dom/index.ts | 4 ++-- src/jsx/dom/jsx-dev-runtime.ts | 2 +- src/jsx/dom/render.ts | 4 ++-- src/jsx/index.test.tsx | 6 +++--- src/jsx/jsx-dev-runtime.ts | 6 +++--- src/jsx/streaming.test.tsx | 4 ++-- src/jsx/streaming.ts | 7 +++++-- src/middleware/jsx-renderer/index.ts | 4 ++-- 19 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/helper/css/index.test.tsx b/src/helper/css/index.test.tsx index df8509491..1fa373501 100644 --- a/src/helper/css/index.test.tsx +++ b/src/helper/css/index.test.tsx @@ -2,7 +2,8 @@ import { Hono } from '../../' import { html } from '../../helper/html' // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { jsx, Fragment, JSXNode } from '../../jsx' +import { jsx, Fragment, isValidElement } from '../../jsx' +import type { JSXNode } from '../../jsx' import { Suspense, renderToReadableStream } from '../../jsx/streaming' import type { HtmlEscapedString } from '../../utils/html' import { HtmlEscapedCallbackPhase, resolveCallback } from '../../utils/html' @@ -15,7 +16,7 @@ async function toString( if (template instanceof Promise) { template = (await template) as HtmlEscapedString } - if (template instanceof JSXNode) { + if (isValidElement(template)) { template = template.toString() as Promise } return resolveCallback(await template, HtmlEscapedCallbackPhase.Stringify, false, template) diff --git a/src/jsx/components.test.tsx b/src/jsx/components.test.tsx index a37313ac9..06f1c47e2 100644 --- a/src/jsx/components.test.tsx +++ b/src/jsx/components.test.tsx @@ -5,7 +5,7 @@ import { HtmlEscapedCallbackPhase, resolveCallback as rawResolveCallback } from import { ErrorBoundary } from './components' import { Suspense, renderToReadableStream } from './streaming' // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { jsx } from './index' +import { jsx } from '.' function resolveCallback(template: string | HtmlEscapedString) { return rawResolveCallback(template, HtmlEscapedCallbackPhase.Stream, false, {}) diff --git a/src/jsx/components.ts b/src/jsx/components.ts index ee0a22906..29d730ef0 100644 --- a/src/jsx/components.ts +++ b/src/jsx/components.ts @@ -4,7 +4,7 @@ import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html' import { DOM_RENDERER } from './constants' import { ErrorBoundary as ErrorBoundaryDomRenderer } from './dom/components' import type { HasRenderToDom } from './dom/render' -import type { FC, Child } from '.' +import type { FC, PropsWithChildren, Child } from '.' let errorBoundaryCounter = 0 @@ -29,11 +29,13 @@ export type FallbackRender = (error: Error) => Child * `ErrorBoundary` is an experimental feature. * The API might be changed. */ -export const ErrorBoundary: FC<{ - fallback?: Child - fallbackRender?: FallbackRender - onError?: ErrorHandler -}> = async ({ children, fallback, fallbackRender, onError }) => { +export const ErrorBoundary: FC< + PropsWithChildren<{ + fallback?: Child + fallbackRender?: FallbackRender + onError?: ErrorHandler + }> +> = async ({ children, fallback, fallbackRender, onError }) => { if (!children) { return raw('') } diff --git a/src/jsx/context.ts b/src/jsx/context.ts index 0e711f663..04afe16a4 100644 --- a/src/jsx/context.ts +++ b/src/jsx/context.ts @@ -1,13 +1,13 @@ import { raw } from '../helper/html' import type { HtmlEscapedString } from '../utils/html' +import { JSXFragmentNode } from './base' import { DOM_RENDERER } from './constants' import { createContextProviderFunction } from './dom/context' -import { JSXFragmentNode } from '.' -import type { FC } from '.' +import type { FC, PropsWithChildren } from '.' export interface Context { values: T[] - Provider: FC<{ value: T }> + Provider: FC> } export const globalContexts: Context[] = [] diff --git a/src/jsx/dom/components.test.tsx b/src/jsx/dom/components.test.tsx index 4c3c91b5d..b1852f234 100644 --- a/src/jsx/dom/components.test.tsx +++ b/src/jsx/dom/components.test.tsx @@ -1,9 +1,9 @@ import { JSDOM } from 'jsdom' +import { Suspense as SuspenseCommon, ErrorBoundary as ErrorBoundaryCommon } from '..' // for common // run tests by old style jsx default // 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 } from '..' -import { Suspense as SuspenseCommon, ErrorBoundary as ErrorBoundaryCommon } from '..' // for common import { use, useState } from '../hooks' import { Suspense as SuspenseDom, ErrorBoundary as ErrorBoundaryDom } from '.' // for dom import { render } from '.' diff --git a/src/jsx/dom/components.ts b/src/jsx/dom/components.ts index dfdbc4849..c7bf31827 100644 --- a/src/jsx/dom/components.ts +++ b/src/jsx/dom/components.ts @@ -1,14 +1,16 @@ -import type { FC, Child } from '..' +import type { FC, PropsWithChildren, Child } from '..' import type { FallbackRender, ErrorHandler } from '../components' import { DOM_ERROR_HANDLER } from '../constants' import { Fragment } from './jsx-runtime' /* eslint-disable @typescript-eslint/no-explicit-any */ -export const ErrorBoundary: FC<{ - fallback?: Child - fallbackRender?: FallbackRender - onError?: ErrorHandler -}> = (({ children, fallback, fallbackRender, onError }: any) => { +export const ErrorBoundary: FC< + PropsWithChildren<{ + fallback?: Child + fallbackRender?: FallbackRender + onError?: ErrorHandler + }> +> = (({ children, fallback, fallbackRender, onError }: any) => { const res = Fragment({ children }) ;(res as any)[DOM_ERROR_HANDLER] = (err: any) => { if (err instanceof Promise) { @@ -20,7 +22,10 @@ export const ErrorBoundary: FC<{ return res }) as any -export const Suspense: FC<{ fallback: any }> = (({ children, fallback }: any) => { +export const Suspense: FC> = (({ + children, + fallback, +}: any) => { const res = Fragment({ children }) ;(res as any)[DOM_ERROR_HANDLER] = (err: any, retry: () => void) => { if (!(err instanceof Promise)) { diff --git a/src/jsx/dom/context.test.tsx b/src/jsx/dom/context.test.tsx index 383595c76..3360ae5b8 100644 --- a/src/jsx/dom/context.test.tsx +++ b/src/jsx/dom/context.test.tsx @@ -1,10 +1,10 @@ import { JSDOM } from 'jsdom' +import { createContext as createContextCommon, useContext as useContextCommon } from '..' // for common +import { use, Suspense } from '..' // run tests by old style jsx default // 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 } from '..' -import { createContext as createContextCommon, useContext as useContextCommon } from '..' // for common -import { use, Suspense } from '..' import { createContext as createContextDom, useContext as useContextDom } from '.' // for dom import { render } from '.' diff --git a/src/jsx/dom/context.ts b/src/jsx/dom/context.ts index 8a670cdaa..6b4a3adbd 100644 --- a/src/jsx/dom/context.ts +++ b/src/jsx/dom/context.ts @@ -1,4 +1,4 @@ -import type { Child } from '..' +import type { Child } from '../base' import { DOM_ERROR_HANDLER } from '../constants' import type { Context } from '../context' import { globalContexts } from '../context' diff --git a/src/jsx/dom/css.test.tsx b/src/jsx/dom/css.test.tsx index b6c216c34..35655e7a4 100644 --- a/src/jsx/dom/css.test.tsx +++ b/src/jsx/dom/css.test.tsx @@ -3,10 +3,10 @@ import { JSDOM } from 'jsdom' // 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 } from '..' +import type { JSXNode } from '..' import { Style, css, rawCssString, createCssContext } from '../../helper/css' import { minify } from '../../helper/css/common' import { renderTest } from '../../helper/css/common.test' -import type { JSXNode } from '../../jsx' import { render } from '.' describe('Style and css for jsx/dom', () => { diff --git a/src/jsx/dom/css.ts b/src/jsx/dom/css.ts index e37195bda..b018dba01 100644 --- a/src/jsx/dom/css.ts +++ b/src/jsx/dom/css.ts @@ -1,3 +1,4 @@ +import type { FC, PropsWithChildren } from '..' import type { CssClassName, CssVariableType } from '../../helper/css/common' import { SELECTOR, @@ -11,7 +12,6 @@ import { keyframesCommon, viewTransitionCommon, } from '../../helper/css/common' -import type { FC } from '../../jsx' export { rawCssString } from '../../helper/css/common' const splitRule = (rule: string): string[] => { @@ -107,7 +107,7 @@ export const createCssJsxDomObjects = ({ id }: { id: Readonly }) => { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - const Style: FC = ({ children }) => + const Style: FC> = ({ children }) => ({ tag: 'style', children: (Array.isArray(children) ? children : [children]).map( diff --git a/src/jsx/dom/index.test.tsx b/src/jsx/dom/index.test.tsx index 743f67682..6299112ff 100644 --- a/src/jsx/dom/index.test.tsx +++ b/src/jsx/dom/index.test.tsx @@ -4,10 +4,10 @@ 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 } from '..' -import { memo, isValidElement, cloneElement } from '..' import type { RefObject } from '../hooks' import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from '../hooks' import type { NodeObject } from './render' +import { memo, isValidElement, cloneElement } from '.' import { render, cloneElement as cloneElementForDom } from '.' const getContainer = (element: JSX.Element): DocumentFragment | HTMLElement | undefined => { diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 7483ab8c7..ef36cfc66 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -17,9 +17,9 @@ export { Suspense, ErrorBoundary } from './components' export { useContext } from '../context' export type { Context } from '../context' export { createContext } from './context' -export { memo, isValidElement } from '..' +export { memo, isValidElement } from '../base' -import type { Props, Child, JSXNode } from '..' +import type { Props, Child, JSXNode } from '../base' import { jsx } from './jsx-runtime' export const cloneElement = ( element: T, diff --git a/src/jsx/dom/jsx-dev-runtime.ts b/src/jsx/dom/jsx-dev-runtime.ts index a472dbc57..cbcd8f8b5 100644 --- a/src/jsx/dom/jsx-dev-runtime.ts +++ b/src/jsx/dom/jsx-dev-runtime.ts @@ -1,4 +1,4 @@ -import type { Props } from '..' +import type { Props } from '../base' import { normalizeIntrinsicElementProps } from '../utils' export const jsxDEV = (tag: string | Function, props: Props, key: string | undefined) => { diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 4e4f66eaa..d6aefbc11 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -1,5 +1,5 @@ -import type { FC, Child, Props } from '..' -import type { JSXNode } from '..' +import type { JSXNode } from '../base' +import type { FC, Child, Props } from '../base' import { DOM_RENDERER, DOM_ERROR_HANDLER, DOM_STASH } from '../constants' import type { Context as JSXContext } from '../context' import { globalContexts as globalJSXContexts } from '../context' diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index 0cd628787..9bd68a10f 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -4,8 +4,8 @@ import { html } from '../helper/html' import { Hono } from '../hono' import { Suspense, renderToReadableStream } from './streaming' // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { jsx, memo, Fragment, createContext, useContext } from './index' -import type { Context, FC } from './index' +import { jsx, memo, Fragment, createContext, useContext } from '.' +import type { Context, FC, PropsWithChildren } from '.' interface SiteData { title: string @@ -321,7 +321,7 @@ describe('render to string', () => { describe('FC', () => { it('Should define the type correctly', () => { - const Layout: FC<{ title: string }> = (props) => { + const Layout: FC> = (props) => { return ( diff --git a/src/jsx/jsx-dev-runtime.ts b/src/jsx/jsx-dev-runtime.ts index 5c135a02e..4396d3fb3 100644 --- a/src/jsx/jsx-dev-runtime.ts +++ b/src/jsx/jsx-dev-runtime.ts @@ -1,7 +1,7 @@ import type { HtmlEscapedString } from '../utils/html' -import { jsxFn } from '.' -import type { JSXNode } from '.' -export { Fragment } from '.' +import { jsxFn } from './base' +import type { JSXNode } from './base' +export { Fragment } from './base' export function jsxDEV( tag: string | Function, diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index 35239580e..aae526d8f 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -3,10 +3,10 @@ import { JSDOM } from 'jsdom' import { raw } from '../helper/html' import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html' import type { HtmlEscapedString } from '../utils/html' +import { jsx, Fragment } from './base' import { use } from './hooks' -import { Suspense, renderToReadableStream } from './streaming' // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { jsx, Fragment } from './index' +import { Suspense, renderToReadableStream } from './streaming' function replacementResult(html: string) { const document = new JSDOM(html, { runScripts: 'dangerously' }).window.document diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index a967f1538..494fc02cf 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -6,7 +6,7 @@ import { DOM_RENDERER, DOM_STASH } from './constants' import { Suspense as SuspenseDomRenderer } from './dom/components' import { buildDataStack } from './dom/render' import type { HasRenderToDom, NodeObject } from './dom/render' -import type { FC, Child } from '.' +import type { FC, PropsWithChildren, Child } from '.' let suspenseCounter = 0 @@ -16,7 +16,10 @@ let suspenseCounter = 0 * The API might be changed. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => { +export const Suspense: FC> = async ({ + children, + fallback, +}) => { if (!children) { return fallback.toString() } diff --git a/src/middleware/jsx-renderer/index.ts b/src/middleware/jsx-renderer/index.ts index 93ee54e26..6c360fb50 100644 --- a/src/middleware/jsx-renderer/index.ts +++ b/src/middleware/jsx-renderer/index.ts @@ -2,7 +2,7 @@ import type { Context, Renderer } from '../../context' import { html, raw } from '../../helper/html' import { jsx, createContext, useContext, Fragment } from '../../jsx' -import type { FC, JSXNode } from '../../jsx' +import type { FC, PropsWithChildren, JSXNode } from '../../jsx' import { renderToReadableStream } from '../../jsx/streaming' import type { Env, Input, MiddlewareHandler } from '../../types' @@ -63,7 +63,7 @@ const createRenderer = } export const jsxRenderer = ( - component?: FC, + component?: FC>, options?: RendererOptions ): MiddlewareHandler => function jsxRenderer(c, next) { From 95b4d132736f0f6c4ed0c150837099916731fb94 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 5 Feb 2024 06:10:33 +0900 Subject: [PATCH 3/4] chore: denoify --- deno_dist/jsx/base.ts | 331 ++++++++++++++++++++ deno_dist/jsx/components.ts | 14 +- deno_dist/jsx/context.ts | 6 +- deno_dist/jsx/dom/components.ts | 19 +- deno_dist/jsx/dom/context.ts | 2 +- deno_dist/jsx/dom/css.ts | 4 +- deno_dist/jsx/dom/index.ts | 4 +- deno_dist/jsx/dom/jsx-dev-runtime.ts | 2 +- deno_dist/jsx/dom/render.ts | 4 +- deno_dist/jsx/index.ts | 339 +-------------------- deno_dist/jsx/jsx-dev-runtime.ts | 6 +- deno_dist/jsx/streaming.ts | 7 +- deno_dist/jsx/types.ts | 11 + deno_dist/middleware/jsx-renderer/index.ts | 4 +- 14 files changed, 385 insertions(+), 368 deletions(-) create mode 100644 deno_dist/jsx/base.ts create mode 100644 deno_dist/jsx/types.ts diff --git a/deno_dist/jsx/base.ts b/deno_dist/jsx/base.ts new file mode 100644 index 000000000..434aad743 --- /dev/null +++ b/deno_dist/jsx/base.ts @@ -0,0 +1,331 @@ +import { raw } from '../helper/html/index.ts' +import { escapeToBuffer, stringBufferToString } from '../utils/html.ts' +import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html.ts' +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' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Props = Record +export type FC = (props: T) => HtmlEscapedString | Promise + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + type Element = HtmlEscapedString | Promise + interface ElementChildrenAttribute { + children: Child + } + interface IntrinsicElements extends IntrinsicElementsDefined { + [tagName: string]: Props + } + } +} + +const emptyTags = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +] +const booleanAttributes = [ + 'allowfullscreen', + 'async', + 'autofocus', + 'autoplay', + 'checked', + 'controls', + 'default', + 'defer', + 'disabled', + 'formnovalidate', + 'hidden', + 'inert', + 'ismap', + 'itemscope', + 'loop', + 'multiple', + 'muted', + 'nomodule', + 'novalidate', + 'open', + 'playsinline', + 'readonly', + 'required', + 'reversed', + 'selected', +] + +const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void => { + for (let i = 0, len = children.length; i < len; i++) { + const child = children[i] + if (typeof child === 'string') { + escapeToBuffer(child, buffer) + } else if (typeof child === 'boolean' || child === null || child === undefined) { + continue + } else if (child instanceof JSXNode) { + child.toStringToBuffer(buffer) + } else if ( + typeof child === 'number' || + (child as unknown as { isEscaped: boolean }).isEscaped + ) { + ;(buffer[0] as string) += child + } else if (child instanceof Promise) { + buffer.unshift('', child) + } else { + // `child` type is `Child[]`, so stringify recursively + childrenToStringToBuffer(child, buffer) + } + } +} + +type LocalContexts = [Context, unknown][] +export type Child = string | Promise | number | JSXNode | Child[] +export class JSXNode implements HtmlEscaped { + tag: string | Function + props: Props + key?: string + children: Child[] + isEscaped: true = true as const + localContexts?: LocalContexts + constructor(tag: string | Function, props: Props, children: Child[]) { + this.tag = tag + this.props = props + this.children = children + } + + toString(): string | Promise { + const buffer: StringBuffer = [''] + this.localContexts?.forEach(([context, value]) => { + context.values.push(value) + }) + try { + this.toStringToBuffer(buffer) + } finally { + this.localContexts?.forEach(([context]) => { + context.values.pop() + }) + } + return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) + } + + toStringToBuffer(buffer: StringBuffer): void { + const tag = this.tag as string + const props = this.props + let { children } = this + + buffer[0] += `<${tag}` + + const propsKeys = Object.keys(props || {}) + + 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(';') + buffer[0] += ` style="${styles}"` + } else if (typeof v === 'string') { + buffer[0] += ` ${key}="` + escapeToBuffer(v, buffer) + buffer[0] += '"' + } else if (v === null || v === undefined) { + // Do nothing + } else if (typeof v === 'number' || (v as HtmlEscaped).isEscaped) { + buffer[0] += ` ${key}="${v}"` + } else if (typeof v === 'boolean' && booleanAttributes.includes(key)) { + if (v) { + buffer[0] += ` ${key}=""` + } + } else if (key === 'dangerouslySetInnerHTML') { + if (children.length > 0) { + throw 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.' + } + + children = [raw(v.__html)] + } else if (v instanceof Promise) { + buffer[0] += ` ${key}="` + buffer.unshift('"', v) + } else { + buffer[0] += ` ${key}="` + escapeToBuffer(v.toString(), buffer) + buffer[0] += '"' + } + } + + if (emptyTags.includes(tag as string)) { + buffer[0] += '/>' + return + } + + buffer[0] += '>' + + childrenToStringToBuffer(children, buffer) + + buffer[0] += `` + } +} + +class JSXFunctionNode extends JSXNode { + toStringToBuffer(buffer: StringBuffer): void { + const { children } = this + + const res = (this.tag as Function).call(null, { + ...this.props, + children: children.length <= 1 ? children[0] : children, + }) + + if (res instanceof Promise) { + if (globalContexts.length === 0) { + buffer.unshift('', res) + } else { + // save current contexts for resuming + const currentContexts: LocalContexts = globalContexts.map((c) => [c, c.values.at(-1)]) + buffer.unshift( + '', + res.then((childRes) => { + if (childRes instanceof JSXNode) { + childRes.localContexts = currentContexts + } + return childRes + }) + ) + } + } else if (res instanceof JSXNode) { + res.toStringToBuffer(buffer) + } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { + buffer[0] += res + } else { + escapeToBuffer(res, buffer) + } + } +} + +export class JSXFragmentNode extends JSXNode { + toStringToBuffer(buffer: StringBuffer): void { + childrenToStringToBuffer(this.children, buffer) + } +} + +export const jsx = ( + tag: string | Function, + props: Props, + ...children: (string | HtmlEscapedString)[] +): JSXNode => { + let key + if (props) { + key = props?.key + delete props['key'] + } + const node = jsxFn(tag, props, children) + node.key = key + return node +} + +export const jsxFn = ( + tag: string | Function, + props: Props, + children: (string | HtmlEscapedString)[] +): JSXNode => { + if (typeof tag === 'function') { + return new JSXFunctionNode(tag, props, children) + } else { + normalizeIntrinsicElementProps(props) + return new JSXNode(tag, props, children) + } +} + +const shallowEqual = (a: Props, b: Props): boolean => { + if (a === b) { + return true + } + + const aKeys = Object.keys(a).sort() + const bKeys = Object.keys(b).sort() + if (aKeys.length !== bKeys.length) { + return false + } + + for (let i = 0, len = aKeys.length; i < len; i++) { + if ( + aKeys[i] === 'children' && + bKeys[i] === 'children' && + !a.children?.length && + !b.children?.length + ) { + continue + } else if (a[aKeys[i]] !== b[aKeys[i]]) { + return false + } + } + + return true +} + +export const memo = ( + component: FC, + propsAreEqual: (prevProps: Readonly, nextProps: Readonly) => boolean = shallowEqual +): FC => { + let computed: HtmlEscapedString | Promise | undefined = undefined + let prevProps: T | undefined = undefined + return ((props: T & { children?: Child }): HtmlEscapedString | Promise => { + if (prevProps && !propsAreEqual(prevProps, props)) { + computed = undefined + } + prevProps = props + return (computed ||= component(props)) + }) as FC +} + +export const Fragment = ({ + children, +}: { + key?: string + children?: Child | HtmlEscapedString +}): HtmlEscapedString => { + return new JSXFragmentNode( + '', + {}, + 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 + ) +} + +export const cloneElement = ( + element: T, + props: Partial, + ...children: Child[] +): T => { + return jsxFn( + (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) || [] + ) as T +} diff --git a/deno_dist/jsx/components.ts b/deno_dist/jsx/components.ts index 768af3cb0..5fb712c06 100644 --- a/deno_dist/jsx/components.ts +++ b/deno_dist/jsx/components.ts @@ -4,7 +4,7 @@ import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html.ts' import { DOM_RENDERER } from './constants.ts' import { ErrorBoundary as ErrorBoundaryDomRenderer } from './dom/components.ts' import type { HasRenderToDom } from './dom/render.ts' -import type { FC, Child } from './index.ts' +import type { FC, PropsWithChildren, Child } from './index.ts' let errorBoundaryCounter = 0 @@ -29,11 +29,13 @@ export type FallbackRender = (error: Error) => Child * `ErrorBoundary` is an experimental feature. * The API might be changed. */ -export const ErrorBoundary: FC<{ - fallback?: Child - fallbackRender?: FallbackRender - onError?: ErrorHandler -}> = async ({ children, fallback, fallbackRender, onError }) => { +export const ErrorBoundary: FC< + PropsWithChildren<{ + fallback?: Child + fallbackRender?: FallbackRender + onError?: ErrorHandler + }> +> = async ({ children, fallback, fallbackRender, onError }) => { if (!children) { return raw('') } diff --git a/deno_dist/jsx/context.ts b/deno_dist/jsx/context.ts index 4a190f3d6..55a8a98de 100644 --- a/deno_dist/jsx/context.ts +++ b/deno_dist/jsx/context.ts @@ -1,13 +1,13 @@ import { raw } from '../helper/html/index.ts' import type { HtmlEscapedString } from '../utils/html.ts' +import { JSXFragmentNode } from './base.ts' import { DOM_RENDERER } from './constants.ts' import { createContextProviderFunction } from './dom/context.ts' -import { JSXFragmentNode } from './index.ts' -import type { FC } from './index.ts' +import type { FC, PropsWithChildren } from './index.ts' export interface Context { values: T[] - Provider: FC<{ value: T }> + Provider: FC> } export const globalContexts: Context[] = [] diff --git a/deno_dist/jsx/dom/components.ts b/deno_dist/jsx/dom/components.ts index 3febe41d6..fe90977c2 100644 --- a/deno_dist/jsx/dom/components.ts +++ b/deno_dist/jsx/dom/components.ts @@ -1,14 +1,16 @@ -import type { FC, Child } from '../index.ts' +import type { FC, PropsWithChildren, Child } from '../index.ts' import type { FallbackRender, ErrorHandler } from '../components.ts' import { DOM_ERROR_HANDLER } from '../constants.ts' import { Fragment } from './jsx-runtime.ts' /* eslint-disable @typescript-eslint/no-explicit-any */ -export const ErrorBoundary: FC<{ - fallback?: Child - fallbackRender?: FallbackRender - onError?: ErrorHandler -}> = (({ children, fallback, fallbackRender, onError }: any) => { +export const ErrorBoundary: FC< + PropsWithChildren<{ + fallback?: Child + fallbackRender?: FallbackRender + onError?: ErrorHandler + }> +> = (({ children, fallback, fallbackRender, onError }: any) => { const res = Fragment({ children }) ;(res as any)[DOM_ERROR_HANDLER] = (err: any) => { if (err instanceof Promise) { @@ -20,7 +22,10 @@ export const ErrorBoundary: FC<{ return res }) as any -export const Suspense: FC<{ fallback: any }> = (({ children, fallback }: any) => { +export const Suspense: FC> = (({ + children, + fallback, +}: any) => { const res = Fragment({ children }) ;(res as any)[DOM_ERROR_HANDLER] = (err: any, retry: () => void) => { if (!(err instanceof Promise)) { diff --git a/deno_dist/jsx/dom/context.ts b/deno_dist/jsx/dom/context.ts index a3f2cd70a..c41b38450 100644 --- a/deno_dist/jsx/dom/context.ts +++ b/deno_dist/jsx/dom/context.ts @@ -1,4 +1,4 @@ -import type { Child } from '../index.ts' +import type { Child } from '../base.ts' import { DOM_ERROR_HANDLER } from '../constants.ts' import type { Context } from '../context.ts' import { globalContexts } from '../context.ts' diff --git a/deno_dist/jsx/dom/css.ts b/deno_dist/jsx/dom/css.ts index 4c87d928f..108589ce2 100644 --- a/deno_dist/jsx/dom/css.ts +++ b/deno_dist/jsx/dom/css.ts @@ -1,3 +1,4 @@ +import type { FC, PropsWithChildren } from '../index.ts' import type { CssClassName, CssVariableType } from '../../helper/css/common.ts' import { SELECTOR, @@ -11,7 +12,6 @@ import { keyframesCommon, viewTransitionCommon, } from '../../helper/css/common.ts' -import type { FC } from '../../jsx/index.ts' export { rawCssString } from '../../helper/css/common.ts' const splitRule = (rule: string): string[] => { @@ -107,7 +107,7 @@ export const createCssJsxDomObjects = ({ id }: { id: Readonly }) => { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - const Style: FC = ({ children }) => + const Style: FC> = ({ children }) => ({ tag: 'style', children: (Array.isArray(children) ? children : [children]).map( diff --git a/deno_dist/jsx/dom/index.ts b/deno_dist/jsx/dom/index.ts index bb3a55489..acab11b78 100644 --- a/deno_dist/jsx/dom/index.ts +++ b/deno_dist/jsx/dom/index.ts @@ -17,9 +17,9 @@ export { Suspense, ErrorBoundary } from './components.ts' export { useContext } from '../context.ts' export type { Context } from '../context.ts' export { createContext } from './context.ts' -export { memo, isValidElement } from '../index.ts' +export { memo, isValidElement } from '../base.ts' -import type { Props, Child, JSXNode } from '../index.ts' +import type { Props, Child, JSXNode } from '../base.ts' import { jsx } from './jsx-runtime.ts' export const cloneElement = ( element: T, diff --git a/deno_dist/jsx/dom/jsx-dev-runtime.ts b/deno_dist/jsx/dom/jsx-dev-runtime.ts index 0b3d11661..239513818 100644 --- a/deno_dist/jsx/dom/jsx-dev-runtime.ts +++ b/deno_dist/jsx/dom/jsx-dev-runtime.ts @@ -1,4 +1,4 @@ -import type { Props } from '../index.ts' +import type { Props } from '../base.ts' import { normalizeIntrinsicElementProps } from '../utils.ts' export const jsxDEV = (tag: string | Function, props: Props, key: string | undefined) => { diff --git a/deno_dist/jsx/dom/render.ts b/deno_dist/jsx/dom/render.ts index e633057f0..6efdcff84 100644 --- a/deno_dist/jsx/dom/render.ts +++ b/deno_dist/jsx/dom/render.ts @@ -1,5 +1,5 @@ -import type { FC, Child, Props } from '../index.ts' -import type { JSXNode } from '../index.ts' +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 type { Context as JSXContext } from '../context.ts' import { globalContexts as globalJSXContexts } from '../context.ts' diff --git a/deno_dist/jsx/index.ts b/deno_dist/jsx/index.ts index 5a69ebb6a..99d1341b5 100644 --- a/deno_dist/jsx/index.ts +++ b/deno_dist/jsx/index.ts @@ -1,11 +1,4 @@ -import { raw } from '../helper/html/index.ts' -import { escapeToBuffer, stringBufferToString } from '../utils/html.ts' -import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html.ts' -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' - +export { jsx, memo, Fragment, isValidElement, cloneElement } from './base.ts' export { ErrorBoundary } from './components.ts' export { Suspense } from './streaming.ts' export { @@ -22,333 +15,5 @@ export { useMemo, useLayoutEffect, } from './hooks/index.ts' -export type { RefObject } from './hooks/index.ts' export { createContext, useContext } from './context.ts' -export type { Context } from './context.ts' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type Props = Record - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - type Element = HtmlEscapedString | Promise - interface ElementChildrenAttribute { - children: Child - } - interface IntrinsicElements extends IntrinsicElementsDefined { - [tagName: string]: Props - } - } -} - -const emptyTags = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'keygen', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', -] -const booleanAttributes = [ - 'allowfullscreen', - 'async', - 'autofocus', - 'autoplay', - 'checked', - 'controls', - 'default', - 'defer', - 'disabled', - 'formnovalidate', - 'hidden', - 'inert', - 'ismap', - 'itemscope', - 'loop', - 'multiple', - 'muted', - 'nomodule', - 'novalidate', - 'open', - 'playsinline', - 'readonly', - 'required', - 'reversed', - 'selected', -] - -const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void => { - for (let i = 0, len = children.length; i < len; i++) { - const child = children[i] - if (typeof child === 'string') { - escapeToBuffer(child, buffer) - } else if (typeof child === 'boolean' || child === null || child === undefined) { - continue - } else if (child instanceof JSXNode) { - child.toStringToBuffer(buffer) - } else if ( - typeof child === 'number' || - (child as unknown as { isEscaped: boolean }).isEscaped - ) { - ;(buffer[0] as string) += child - } else if (child instanceof Promise) { - buffer.unshift('', child) - } else { - // `child` type is `Child[]`, so stringify recursively - childrenToStringToBuffer(child, buffer) - } - } -} - -type LocalContexts = [Context, unknown][] -export type Child = string | Promise | number | JSXNode | Child[] -export class JSXNode implements HtmlEscaped { - tag: string | Function - props: Props - key?: string - children: Child[] - isEscaped: true = true as const - localContexts?: LocalContexts - constructor(tag: string | Function, props: Props, children: Child[]) { - this.tag = tag - this.props = props - this.children = children - } - - toString(): string | Promise { - const buffer: StringBuffer = [''] - this.localContexts?.forEach(([context, value]) => { - context.values.push(value) - }) - try { - this.toStringToBuffer(buffer) - } finally { - this.localContexts?.forEach(([context]) => { - context.values.pop() - }) - } - return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) - } - - toStringToBuffer(buffer: StringBuffer): void { - const tag = this.tag as string - const props = this.props - let { children } = this - - buffer[0] += `<${tag}` - - const propsKeys = Object.keys(props || {}) - - 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(';') - buffer[0] += ` style="${styles}"` - } else if (typeof v === 'string') { - buffer[0] += ` ${key}="` - escapeToBuffer(v, buffer) - buffer[0] += '"' - } else if (v === null || v === undefined) { - // Do nothing - } else if (typeof v === 'number' || (v as HtmlEscaped).isEscaped) { - buffer[0] += ` ${key}="${v}"` - } else if (typeof v === 'boolean' && booleanAttributes.includes(key)) { - if (v) { - buffer[0] += ` ${key}=""` - } - } else if (key === 'dangerouslySetInnerHTML') { - if (children.length > 0) { - throw 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.' - } - - children = [raw(v.__html)] - } else if (v instanceof Promise) { - buffer[0] += ` ${key}="` - buffer.unshift('"', v) - } else { - buffer[0] += ` ${key}="` - escapeToBuffer(v.toString(), buffer) - buffer[0] += '"' - } - } - - if (emptyTags.includes(tag as string)) { - buffer[0] += '/>' - return - } - - buffer[0] += '>' - - childrenToStringToBuffer(children, buffer) - - buffer[0] += `` - } -} - -class JSXFunctionNode extends JSXNode { - toStringToBuffer(buffer: StringBuffer): void { - const { children } = this - - const res = (this.tag as Function).call(null, { - ...this.props, - children: children.length <= 1 ? children[0] : children, - }) - - if (res instanceof Promise) { - if (globalContexts.length === 0) { - buffer.unshift('', res) - } else { - // save current contexts for resuming - const currentContexts: LocalContexts = globalContexts.map((c) => [c, c.values.at(-1)]) - buffer.unshift( - '', - res.then((childRes) => { - if (childRes instanceof JSXNode) { - childRes.localContexts = currentContexts - } - return childRes - }) - ) - } - } else if (res instanceof JSXNode) { - res.toStringToBuffer(buffer) - } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { - buffer[0] += res - } else { - escapeToBuffer(res, buffer) - } - } -} - -export class JSXFragmentNode extends JSXNode { - toStringToBuffer(buffer: StringBuffer): void { - childrenToStringToBuffer(this.children, buffer) - } -} - -export const jsx = ( - tag: string | Function, - props: Props, - ...children: (string | HtmlEscapedString)[] -): JSXNode => { - let key - if (props) { - key = props?.key - delete props['key'] - } - const node = jsxFn(tag, props, children) - node.key = key - return node -} - -export const jsxFn = ( - tag: string | Function, - props: Props, - children: (string | HtmlEscapedString)[] -): JSXNode => { - if (typeof tag === 'function') { - return new JSXFunctionNode(tag, props, children) - } else { - normalizeIntrinsicElementProps(props) - return new JSXNode(tag, props, children) - } -} - -export type FC = ( - props: T & { children?: Child } -) => HtmlEscapedString | Promise - -const shallowEqual = (a: Props, b: Props): boolean => { - if (a === b) { - return true - } - - const aKeys = Object.keys(a).sort() - const bKeys = Object.keys(b).sort() - if (aKeys.length !== bKeys.length) { - return false - } - - for (let i = 0, len = aKeys.length; i < len; i++) { - if ( - aKeys[i] === 'children' && - bKeys[i] === 'children' && - !a.children?.length && - !b.children?.length - ) { - continue - } else if (a[aKeys[i]] !== b[aKeys[i]]) { - return false - } - } - - return true -} - -export const memo = ( - component: FC, - propsAreEqual: (prevProps: Readonly, nextProps: Readonly) => boolean = shallowEqual -): FC => { - let computed: HtmlEscapedString | Promise | undefined = undefined - let prevProps: T | undefined = undefined - return ((props: T & { children?: Child }): HtmlEscapedString | Promise => { - if (prevProps && !propsAreEqual(prevProps, props)) { - computed = undefined - } - prevProps = props - return (computed ||= component(props)) - }) as FC -} - -export const Fragment = ({ - children, -}: { - key?: string - children?: Child | HtmlEscapedString -}): HtmlEscapedString => { - return new JSXFragmentNode( - '', - {}, - 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 - ) -} - -export const cloneElement = ( - element: T, - props: Partial, - ...children: Child[] -): T => { - return jsxFn( - (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) || [] - ) as T -} +export type * from './types' diff --git a/deno_dist/jsx/jsx-dev-runtime.ts b/deno_dist/jsx/jsx-dev-runtime.ts index c06d4879f..75f42eba6 100644 --- a/deno_dist/jsx/jsx-dev-runtime.ts +++ b/deno_dist/jsx/jsx-dev-runtime.ts @@ -1,7 +1,7 @@ import type { HtmlEscapedString } from '../utils/html.ts' -import { jsxFn } from './index.ts' -import type { JSXNode } from './index.ts' -export { Fragment } from './index.ts' +import { jsxFn } from './base.ts' +import type { JSXNode } from './base.ts' +export { Fragment } from './base.ts' export function jsxDEV( tag: string | Function, diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts index 22f5e9ee9..12e83d4c1 100644 --- a/deno_dist/jsx/streaming.ts +++ b/deno_dist/jsx/streaming.ts @@ -6,7 +6,7 @@ import { DOM_RENDERER, DOM_STASH } from './constants.ts' import { Suspense as SuspenseDomRenderer } from './dom/components.ts' import { buildDataStack } from './dom/render.ts' import type { HasRenderToDom, NodeObject } from './dom/render.ts' -import type { FC, Child } from './index.ts' +import type { FC, PropsWithChildren, Child } from './index.ts' let suspenseCounter = 0 @@ -16,7 +16,10 @@ let suspenseCounter = 0 * The API might be changed. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => { +export const Suspense: FC> = async ({ + children, + fallback, +}) => { if (!children) { return fallback.toString() } diff --git a/deno_dist/jsx/types.ts b/deno_dist/jsx/types.ts new file mode 100644 index 000000000..18b124ce4 --- /dev/null +++ b/deno_dist/jsx/types.ts @@ -0,0 +1,11 @@ +/** + * All types exported from "hono/jsx" are in this file. + */ +import type { Child } from './base.ts' + +export type { Child, JSXNode, FC } from './base.ts' +export type { RefObject } from './hooks/index.ts' +export type { Context } from './context.ts' + +export type PropsWithChildren

= P & { children?: Child | undefined } +export type CSSProperties = Hono.CSSProperties diff --git a/deno_dist/middleware/jsx-renderer/index.ts b/deno_dist/middleware/jsx-renderer/index.ts index dd781f412..dcc190595 100644 --- a/deno_dist/middleware/jsx-renderer/index.ts +++ b/deno_dist/middleware/jsx-renderer/index.ts @@ -2,7 +2,7 @@ import type { Context, Renderer } from '../../context.ts' import { html, raw } from '../../helper/html/index.ts' import { jsx, createContext, useContext, Fragment } from '../../jsx/index.ts' -import type { FC, JSXNode } from '../../jsx/index.ts' +import type { FC, PropsWithChildren, JSXNode } from '../../jsx/index.ts' import { renderToReadableStream } from '../../jsx/streaming.ts' import type { Env, Input, MiddlewareHandler } from '../../types.ts' @@ -63,7 +63,7 @@ const createRenderer = } export const jsxRenderer = ( - component?: FC, + component?: FC>, options?: RendererOptions ): MiddlewareHandler => function jsxRenderer(c, next) { From 874ca9951775452fa677ce1a7dbd8bd747a36f7d Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 5 Feb 2024 08:19:57 +0900 Subject: [PATCH 4/4] fix(jsx): use `export *` for now until denoify bug is fixed --- deno_dist/jsx/index.ts | 5 ++++- src/jsx/index.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/deno_dist/jsx/index.ts b/deno_dist/jsx/index.ts index 99d1341b5..8bd38da01 100644 --- a/deno_dist/jsx/index.ts +++ b/deno_dist/jsx/index.ts @@ -16,4 +16,7 @@ export { useLayoutEffect, } from './hooks/index.ts' export { createContext, useContext } from './context.ts' -export type * from './types' + +// TODO: change to `export type *` after denoify bug is fixed +// https://github.com/garronej/denoify/issues/124 +export * from './types.ts' diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 668fbfbc9..a7a00cd11 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -16,4 +16,7 @@ export { useLayoutEffect, } from './hooks' export { createContext, useContext } from './context' -export type * from './types' + +// TODO: change to `export type *` after denoify bug is fixed +// https://github.com/garronej/denoify/issues/124 +export * from './types'