Skip to content

Commit

Permalink
feat(jsx/dom): introduce react-dom/client APIs and React.version (#2795)
Browse files Browse the repository at this point in the history
* feat(jsx): export version string from jsx

* feat(jsx/dom): introduce react-dom/client APIs

* docs(jsx/dom/client): add module description

* chore: add jsx/dom/client to jsr.json

* refactor(jsx/dom): declare types explicitly.
  • Loading branch information
usualoma authored May 26, 2024
1 parent 6d9f1f0 commit 593cb7a
Show file tree
Hide file tree
Showing 10 changed files with 243 additions and 7 deletions.
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"./jsx/dom": "./src/jsx/dom/index.ts",
"./jsx/dom/jsx-dev-runtime": "./src/jsx/dom/jsx-dev-runtime.ts",
"./jsx/dom/jsx-runtime": "./src/jsx/dom/jsx-runtime.ts",
"./jsx/dom/client": "./src/jsx/dom/client.ts",
"./jsx/dom/css": "./src/jsx/dom/css.ts",
"./jwt": "./src/middleware/jwt/jwt.ts",
"./timing": "./src/middleware/timing/timing.ts",
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@
"import": "./dist/jsx/dom/jsx-runtime.js",
"require": "./dist/cjs/jsx/dom/jsx-runtime.js"
},
"./jsx/dom/client": {
"types": "./dist/types/jsx/dom/client.d.ts",
"import": "./dist/jsx/dom/client.js",
"require": "./dist/cjs/jsx/dom/client.js"
},
"./jsx/dom/css": {
"types": "./dist/types/jsx/dom/css.d.ts",
"import": "./dist/jsx/dom/css.js",
Expand Down Expand Up @@ -423,6 +428,9 @@
"jsx/dom": [
"./dist/types/jsx/dom"
],
"jsx/dom/client": [
"./dist/types/jsx/dom/client.d.ts"
],
"jsx/dom/css": [
"./dist/types/jsx/dom/css.d.ts"
],
Expand Down
2 changes: 2 additions & 0 deletions src/jsx/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,5 @@ export const cloneElement = <T extends JSXNode | JSX.Element>(
...(children as (string | number | HtmlEscapedString)[])
) as T
}

export const reactAPICompatVersion = '18.0.0-hono-jsx'
119 changes: 119 additions & 0 deletions src/jsx/dom/client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/** @jsxImportSource ../ */
import { JSDOM } from 'jsdom'
import { createRoot, hydrateRoot } from './client'
import { useEffect } from '.'

describe('createRoot', () => {
beforeAll(() => {
global.requestAnimationFrame = (cb) => setTimeout(cb)
})

let dom: JSDOM
let rootElement: HTMLElement
beforeEach(() => {
dom = new JSDOM('<html><body><div id="root"></div></body></html>', {
runScripts: 'dangerously',
})
global.document = dom.window.document
global.HTMLElement = dom.window.HTMLElement
global.SVGElement = dom.window.SVGElement
global.Text = dom.window.Text
rootElement = document.getElementById('root') as HTMLElement
})

it('render / unmount', async () => {
const cleanup = vi.fn()
const App = () => {
useEffect(() => cleanup, [])
return <h1>Hello</h1>
}
const root = createRoot(rootElement)
root.render(<App />)
expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
await new Promise((resolve) => setTimeout(resolve))
root.unmount()
await Promise.resolve()
expect(rootElement.innerHTML).toBe('')
expect(cleanup).toHaveBeenCalled()
})

it('call render twice', async () => {
const App = <h1>Hello</h1>
const App2 = <h1>World</h1>
const root = createRoot(rootElement)
root.render(App)
expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')

const createElementSpy = vi.spyOn(dom.window.document, 'createElement')

root.render(App2)
await Promise.resolve()
expect(rootElement.innerHTML).toBe('<h1>World</h1>')

expect(createElementSpy).not.toHaveBeenCalled()
})

it('call render after unmount', async () => {
const App = <h1>Hello</h1>
const App2 = <h1>World</h1>
const root = createRoot(rootElement)
root.render(App)
expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
root.unmount()
expect(() => root.render(App2)).toThrow('Cannot update an unmounted root')
})
})

describe('hydrateRoot', () => {
let dom: JSDOM
let rootElement: HTMLElement
beforeEach(() => {
dom = new JSDOM('<html><body><div id="root"></div></body></html>', {
runScripts: 'dangerously',
})
global.document = dom.window.document
global.HTMLElement = dom.window.HTMLElement
global.SVGElement = dom.window.SVGElement
global.Text = dom.window.Text
rootElement = document.getElementById('root') as HTMLElement
})

it('should return root object', async () => {
const cleanup = vi.fn()
const App = () => {
useEffect(() => cleanup, [])
return <h1>Hello</h1>
}
const root = hydrateRoot(rootElement, <App />)
expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
await new Promise((resolve) => setTimeout(resolve))
root.unmount()
await Promise.resolve()
expect(rootElement.innerHTML).toBe('')
expect(cleanup).toHaveBeenCalled()
})

it('call render', async () => {
const App = <h1>Hello</h1>
const App2 = <h1>World</h1>
const root = hydrateRoot(rootElement, App)
expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')

const createElementSpy = vi.spyOn(dom.window.document, 'createElement')

root.render(App2)
await Promise.resolve()
expect(rootElement.innerHTML).toBe('<h1>World</h1>')

expect(createElementSpy).not.toHaveBeenCalled()
})

it('call render after unmount', async () => {
const App = <h1>Hello</h1>
const App2 = <h1>World</h1>
const root = hydrateRoot(rootElement, App)
expect(rootElement.innerHTML).toBe('<h1>Hello</h1>')
root.unmount()
expect(() => root.render(App2)).toThrow('Cannot update an unmounted root')
})
})
84 changes: 84 additions & 0 deletions src/jsx/dom/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @module
* This module provides APIs for `hono/jsx/dom/client`, which is compatible with `react-dom/client`.
*/

import type { Child } from '../base'
import { useState } from '../hooks'
import { buildNode, renderNode } from './render'
import type { NodeObject } from './render'

export interface Root {
render(children: Child): void
unmount(): void
}
export type RootOptions = Record<string, unknown>

/**
* Create a root object for rendering
* @param element Render target
* @param options Options for createRoot (not supported yet)
* @returns Root object has `render` and `unmount` methods
*/
export const createRoot = (
element: HTMLElement | DocumentFragment,
options: RootOptions = {}
): Root => {
let setJsxNode:
| undefined // initial state
| ((jsxNode: unknown) => void) // rendered
| null = // unmounted
undefined

if (Object.keys(options).length > 0) {
console.warn('createRoot options are not supported yet')
}

return {
render(jsxNode: unknown) {
if (setJsxNode === null) {
// unmounted
throw new Error('Cannot update an unmounted root')
}
if (setJsxNode) {
// rendered
setJsxNode(jsxNode)
} else {
renderNode(
buildNode({
tag: () => {
const [_jsxNode, _setJsxNode] = useState(jsxNode)
setJsxNode = _setJsxNode
return _jsxNode
},
props: {},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any) as NodeObject,
element
)
}
},
unmount() {
setJsxNode?.(null)
setJsxNode = null
},
}
}

/**
* Create a root object and hydrate app to the target element.
* In hono/jsx/dom, hydrate is equivalent to render.
* @param element Render target
* @param reactNode A JSXNode to render
* @param options Options for createRoot (not supported yet)
* @returns Root object has `render` and `unmount` methods
*/
export const hydrateRoot = (
element: HTMLElement | DocumentFragment,
reactNode: Child,
options: RootOptions = {}
): Root => {
const root = createRoot(element, options)
root.render(reactNode)
return root
}
8 changes: 8 additions & 0 deletions src/jsx/dom/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import DefaultExport, {
isValidElement,
memo,
render,
version,
} from '.'

describe('Common', () => {
Expand Down Expand Up @@ -2123,8 +2124,15 @@ describe('jsx', () => {
})
})

describe('version', () => {
it('should be defined with semantic versioning format', () => {
expect(version).toMatch(/^\d+\.\d+\.\d+-hono-jsx$/)
})
})

describe('default export', () => {
;[
'version',
'memo',
'Fragment',
'isValidElement',
Expand Down
4 changes: 3 additions & 1 deletion src/jsx/dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* This module provides APIs for `hono/jsx/dom`.
*/

import { isValidElement, memo } from '../base'
import { isValidElement, memo, reactAPICompatVersion } from '../base'
import type { Child, DOMAttributes, JSX, JSXNode, Props } from '../base'
import { Children } from '../children'
import { useContext } from '../context'
Expand Down Expand Up @@ -72,6 +72,7 @@ const cloneElement = <T extends JSXNode | JSX.Element>(
}

export {
reactAPICompatVersion as version,
createElement as jsx,
useState,
useEffect,
Expand Down Expand Up @@ -109,6 +110,7 @@ export {
}

export default {
version: reactAPICompatVersion,
useState,
useEffect,
useRef,
Expand Down
11 changes: 7 additions & 4 deletions src/jsx/dom/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ export const build = (
}
}

const buildNode = (node: Child): Node | undefined => {
export const buildNode = (node: Child): Node | undefined => {
if (node === undefined || node === null || typeof node === 'boolean') {
return undefined
} else if (typeof node === 'string' || typeof node === 'number') {
Expand Down Expand Up @@ -590,9 +590,7 @@ export const update = async (
return promise
}

export const render = (jsxNode: unknown, container: Container) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const node = buildNode({ tag: '', props: { children: jsxNode } } as any) as NodeObject
export const renderNode = (node: NodeObject, container: Container) => {
const context: Context = []
;(context as Context)[4] = true // start top level render
build(context, node, undefined)
Expand All @@ -604,6 +602,11 @@ export const render = (jsxNode: unknown, container: Container) => {
container.replaceChildren(fragment)
}

export const render = (jsxNode: Child, container: Container) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderNode(buildNode({ tag: '', props: { children: jsxNode } } as any) as NodeObject, container)
}

export const flushSync = (callback: () => void) => {
const set = new Set<NodeObject>()
currentUpdateSets.push(set)
Expand Down
9 changes: 8 additions & 1 deletion src/jsx/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { html } from '../helper/html'
import { Hono } from '../hono'
import { Suspense, renderToReadableStream } from './streaming'
import DefaultExport, { Fragment, createContext, memo, useContext } from '.'
import DefaultExport, { Fragment, createContext, memo, useContext, version } from '.'
import type { Context, FC, PropsWithChildren } from '.'

interface SiteData {
Expand Down Expand Up @@ -729,8 +729,15 @@ d.replaceWith(c.content)
})
})

describe('version', () => {
it('should be defined with semantic versioning format', () => {
expect(version).toMatch(/^\d+\.\d+\.\d+-hono-jsx$/)
})
})

describe('default export', () => {
;[
'version',
'memo',
'Fragment',
'isValidElement',
Expand Down
4 changes: 3 additions & 1 deletion src/jsx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* JSX for Hono.
*/

import { Fragment, cloneElement, isValidElement, jsx, memo } from './base'
import { Fragment, cloneElement, isValidElement, jsx, memo, reactAPICompatVersion } from './base'
import type { DOMAttributes } from './base'
import { Children } from './children'
import { ErrorBoundary } from './components'
Expand Down Expand Up @@ -33,6 +33,7 @@ import {
import { Suspense } from './streaming'

export {
reactAPICompatVersion as version,
jsx,
memo,
Fragment,
Expand Down Expand Up @@ -68,6 +69,7 @@ export {
}

export default {
version: reactAPICompatVersion,
memo,
Fragment,
isValidElement,
Expand Down

0 comments on commit 593cb7a

Please sign in to comment.