diff --git a/src/jsx/base.ts b/src/jsx/base.ts index 684edfbe8..64c35ae86 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -3,6 +3,7 @@ import { escapeToBuffer, stringBufferToString } from '../utils/html' import type { HtmlEscaped, HtmlEscapedString, StringBuffer } from '../utils/html' import type { Context } from './context' import { globalContexts } from './context' +import type { Component } from './component' import type { Hono, IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements' import { normalizeIntrinsicElementProps, styleObjectForEach } from './utils' @@ -26,6 +27,8 @@ export namespace JSX { } } +export const toFunctionComponent = Symbol() + const emptyTags = [ 'area', 'base', @@ -273,15 +276,19 @@ export const jsx = ( } export const jsxFn = ( - tag: string | Function, + tag: string | Function | Component, props: Props, children: (string | number | HtmlEscapedString)[] ): JSXNode => { if (typeof tag === 'function') { + if (toFunctionComponent in tag) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tag = (tag as any)[toFunctionComponent]() as Function + } return new JSXFunctionNode(tag, props, children) } else { normalizeIntrinsicElementProps(props) - return new JSXNode(tag, props, children) + return new JSXNode(tag as string, props, children) } } diff --git a/src/jsx/component.ts b/src/jsx/component.ts new file mode 100644 index 000000000..3af0165d2 --- /dev/null +++ b/src/jsx/component.ts @@ -0,0 +1,103 @@ +import type { HtmlEscapedString } from '../utils/html' +import { jsx, memo, toFunctionComponent } from './base' +import type { FC, Props } from './base' +import { ErrorBoundary } from './components' +import { useEffect, useState } from './hooks' +import type { Context } from './context' +import { useContext } from './context' + +const functionComponent = Symbol() + +export abstract class Component { + static contextType?: Context + static [functionComponent]: FC | undefined = undefined + static [toFunctionComponent]( + this: (new (props: Props) => Component) & { + contextType?: Context + [functionComponent]: FC | undefined + getDerivedStateFromProps?: ( + nextProps: unknown, + prevState: unknown + ) => Record | null + } + ): FC { + return (this[functionComponent] ||= (props: Props) => { + let instance: Component | undefined = undefined + let rerender = true + if (props.children) { + const onError = (error: Error) => instance!.componentDidCatch(error) + props.children = jsx( + ErrorBoundary, + { + onError, + }, + props.children as HtmlEscapedString + ) + } + ;[instance] = useState(() => { + rerender = false + return new this(props) + }) + // eslint-disable-next-line @typescript-eslint/unbound-method + ;[instance.state, instance.setState] = useState(instance.state) + const [, forceUpdate] = useState(true) + + useEffect(() => { + instance.componentDidMount() + return () => instance.componentWillUnmount() + }, []) + + useEffect(() => { + if (rerender) { + instance.componentDidUpdate() + } + }) + + if (rerender) { + if (this.getDerivedStateFromProps) { + props = this.getDerivedStateFromProps(props, instance!.state) as Record + } + if (props !== null) { + instance!.props = props + } + } else { + if (this.contextType) { + instance!.context = useContext(this.contextType) + } + instance.forceUpdate = (cb) => { + forceUpdate((current) => !current) + cb() + } + } + return instance.render() + }) + } + + state: unknown + props: Record = {} + context: unknown + + constructor(props: Record | undefined) { + this.props = props || {} + } + + render(): HtmlEscapedString | Promise { + throw new Error('Component subclasses must implement a render method') + } + + /* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars */ + componentDidCatch(error: Error) {} + setState(newState: unknown): void {} + componentDidMount(): void {} + componentDidUpdate(): void {} + componentWillUnmount(): void {} + forceUpdate(callback: Function): void {} + /* eslint-enable @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars */ +} + +export abstract class PureComponent extends Component { + static [toFunctionComponent](this: new (props: Record) => Component) { + const render = super[toFunctionComponent]() + return memo(render) + } +} diff --git a/src/jsx/dom/component.test.tsx b/src/jsx/dom/component.test.tsx new file mode 100644 index 000000000..feafef11b --- /dev/null +++ b/src/jsx/dom/component.test.tsx @@ -0,0 +1,52 @@ +/** @jsxImportSource ../ */ +import { JSDOM } from 'jsdom' +import { Component } from '../component' +import { render, useState } from '.' + +describe('Component', () => { + beforeAll(() => { + global.requestAnimationFrame = (cb) => setTimeout(cb) + }) + + let dom: JSDOM + let root: HTMLElement + beforeEach(() => { + dom = new JSDOM('
', { + runScripts: 'dangerously', + }) + global.document = dom.window.document + global.HTMLElement = dom.window.HTMLElement + global.Text = dom.window.Text + root = document.getElementById('root') as HTMLElement + }) + + it('render component', async () => { + class App extends Component { + render() { + return
Hello
+ } + } + render(, root) + expect(root.innerHTML).toBe('
Hello
') + }) + + it('update props', async () => { + class C extends Component { + render() { + return
{this.props.count}
+ } + } + const App = () => { + const [count, setCount] = useState(0) + return <> + + + + } + render(, root) + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')!.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
1
') + }) +}) diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index b8676b65b..ee23e2172 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -7,6 +7,7 @@ import { isValidElement, memo, reactAPICompatVersion } from '../base' import type { Child, DOMAttributes, JSX, JSXNode, Props } from '../base' import { Children } from '../children' import { useContext } from '../context' +import { Component } from '../component' import { createRef, forwardRef, @@ -72,6 +73,7 @@ const cloneElement = ( } export { + Component, reactAPICompatVersion as version, createElement as jsx, useState, @@ -110,6 +112,7 @@ export { } export default { + Component, version: reactAPICompatVersion, useState, useEffect, diff --git a/src/jsx/dom/jsx-dev-runtime.ts b/src/jsx/dom/jsx-dev-runtime.ts index 31548b7aa..a36d6fd32 100644 --- a/src/jsx/dom/jsx-dev-runtime.ts +++ b/src/jsx/dom/jsx-dev-runtime.ts @@ -6,8 +6,9 @@ import type { JSXNode, Props } from '../base' import { normalizeIntrinsicElementProps } from '../utils' import { newJSXNode } from './utils' +import type { Component } from '../component' -export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXNode => { +export const jsxDEV = (tag: string | Function | Component, props: Props, key?: string): JSXNode => { if (typeof tag === 'string') { normalizeIntrinsicElementProps(props) } diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 530b0eb1a..5f863fb82 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -1,3 +1,4 @@ +import { toFunctionComponent } from '../base' import type { Child, FC, JSXNode, Props } from '../base' import { toArray } from '../children' import { DOM_ERROR_HANDLER, DOM_INTERNAL_TAG, DOM_RENDERER, DOM_STASH } from '../constants' @@ -505,6 +506,11 @@ export const buildNode = (node: Child): Node | undefined => { }) } if (typeof (node as JSXNode).tag === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (toFunctionComponent in (node as any).tag) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(node as JSXNode).tag = ((node as JSXNode).tag as any)[toFunctionComponent]() + } ;(node as NodeObject)[DOM_STASH] = [0, []] } else { const ns = nameSpaceMap[(node as JSXNode).tag as string] diff --git a/src/jsx/dom/utils.ts b/src/jsx/dom/utils.ts index 8cbc605d9..db59d81c7 100644 --- a/src/jsx/dom/utils.ts +++ b/src/jsx/dom/utils.ts @@ -1,5 +1,6 @@ import type { JSXNode, Props } from '../base' import { DOM_INTERNAL_TAG } from '../constants' +import type { Component } from '../component' export const setInternalTagFlag = (fn: Function): Function => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -20,5 +21,8 @@ const JSXNodeCompatPrototype = { }, } -export const newJSXNode = (obj: { tag: string | Function; props?: Props; key?: string }): JSXNode => - Object.defineProperties(obj, JSXNodeCompatPrototype) as JSXNode +export const newJSXNode = (obj: { + tag: string | Function | Component + props?: Props + key?: string +}): JSXNode => Object.defineProperties(obj, JSXNodeCompatPrototype) as JSXNode diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 8d233ea27..0f7fe8772 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -8,6 +8,7 @@ import type { DOMAttributes } from './base' import { Children } from './children' import { ErrorBoundary } from './components' import { createContext, useContext } from './context' +import { Component } from './component' import { createRef, forwardRef, @@ -33,6 +34,7 @@ import { import { Suspense } from './streaming' export { + Component, reactAPICompatVersion as version, jsx, memo, @@ -69,6 +71,7 @@ export { } export default { + Component, version: reactAPICompatVersion, memo, Fragment, diff --git a/src/jsx/jsx-dev-runtime.ts b/src/jsx/jsx-dev-runtime.ts index 90f45cbd9..d18626def 100644 --- a/src/jsx/jsx-dev-runtime.ts +++ b/src/jsx/jsx-dev-runtime.ts @@ -8,9 +8,10 @@ import { jsxFn } from './base' import type { JSXNode } from './base' export { Fragment } from './base' export type { JSX } from './base' +import type { Component } from './component' export function jsxDEV( - tag: string | Function, + tag: string | Function | Component, props: Record, key?: string ): JSXNode {