From 593cb7a89d54e667cf86d31a404cbd4b8dc1c684 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 May 2024 22:55:30 +0900 Subject: [PATCH] feat(jsx/dom): introduce react-dom/client APIs and React.version (#2795) * 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. --- jsr.json | 1 + package.json | 8 +++ src/jsx/base.ts | 2 + src/jsx/dom/client.test.tsx | 119 ++++++++++++++++++++++++++++++++++++ src/jsx/dom/client.ts | 84 +++++++++++++++++++++++++ src/jsx/dom/index.test.tsx | 8 +++ src/jsx/dom/index.ts | 4 +- src/jsx/dom/render.ts | 11 ++-- src/jsx/index.test.tsx | 9 ++- src/jsx/index.ts | 4 +- 10 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 src/jsx/dom/client.test.tsx create mode 100644 src/jsx/dom/client.ts diff --git a/jsr.json b/jsr.json index 418ef1a0d..373f57813 100644 --- a/jsr.json +++ b/jsr.json @@ -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", diff --git a/package.json b/package.json index ded87aa0c..fcca86eaf 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" ], diff --git a/src/jsx/base.ts b/src/jsx/base.ts index 610a01e4f..684edfbe8 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -357,3 +357,5 @@ export const cloneElement = ( ...(children as (string | number | HtmlEscapedString)[]) ) as T } + +export const reactAPICompatVersion = '18.0.0-hono-jsx' diff --git a/src/jsx/dom/client.test.tsx b/src/jsx/dom/client.test.tsx new file mode 100644 index 000000000..8ebe07798 --- /dev/null +++ b/src/jsx/dom/client.test.tsx @@ -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('
', { + 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

Hello

+ } + const root = createRoot(rootElement) + root.render() + expect(rootElement.innerHTML).toBe('

Hello

') + 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 =

Hello

+ const App2 =

World

+ const root = createRoot(rootElement) + root.render(App) + expect(rootElement.innerHTML).toBe('

Hello

') + + const createElementSpy = vi.spyOn(dom.window.document, 'createElement') + + root.render(App2) + await Promise.resolve() + expect(rootElement.innerHTML).toBe('

World

') + + expect(createElementSpy).not.toHaveBeenCalled() + }) + + it('call render after unmount', async () => { + const App =

Hello

+ const App2 =

World

+ const root = createRoot(rootElement) + root.render(App) + expect(rootElement.innerHTML).toBe('

Hello

') + root.unmount() + expect(() => root.render(App2)).toThrow('Cannot update an unmounted root') + }) +}) + +describe('hydrateRoot', () => { + let dom: JSDOM + let rootElement: HTMLElement + beforeEach(() => { + dom = new JSDOM('
', { + 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

Hello

+ } + const root = hydrateRoot(rootElement, ) + expect(rootElement.innerHTML).toBe('

Hello

') + await new Promise((resolve) => setTimeout(resolve)) + root.unmount() + await Promise.resolve() + expect(rootElement.innerHTML).toBe('') + expect(cleanup).toHaveBeenCalled() + }) + + it('call render', async () => { + const App =

Hello

+ const App2 =

World

+ const root = hydrateRoot(rootElement, App) + expect(rootElement.innerHTML).toBe('

Hello

') + + const createElementSpy = vi.spyOn(dom.window.document, 'createElement') + + root.render(App2) + await Promise.resolve() + expect(rootElement.innerHTML).toBe('

World

') + + expect(createElementSpy).not.toHaveBeenCalled() + }) + + it('call render after unmount', async () => { + const App =

Hello

+ const App2 =

World

+ const root = hydrateRoot(rootElement, App) + expect(rootElement.innerHTML).toBe('

Hello

') + root.unmount() + expect(() => root.render(App2)).toThrow('Cannot update an unmounted root') + }) +}) diff --git a/src/jsx/dom/client.ts b/src/jsx/dom/client.ts new file mode 100644 index 000000000..5bf754170 --- /dev/null +++ b/src/jsx/dom/client.ts @@ -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 + +/** + * 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 +} diff --git a/src/jsx/dom/index.test.tsx b/src/jsx/dom/index.test.tsx index d9f11a75e..e6c023f93 100644 --- a/src/jsx/dom/index.test.tsx +++ b/src/jsx/dom/index.test.tsx @@ -24,6 +24,7 @@ import DefaultExport, { isValidElement, memo, render, + version, } from '.' describe('Common', () => { @@ -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', diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 5c3337db5..b8676b65b 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -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' @@ -72,6 +72,7 @@ const cloneElement = ( } export { + reactAPICompatVersion as version, createElement as jsx, useState, useEffect, @@ -109,6 +110,7 @@ export { } export default { + version: reactAPICompatVersion, useState, useEffect, useRef, diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 4ce2ea4d3..50978dc45 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -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') { @@ -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) @@ -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() currentUpdateSets.push(set) diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index 59d6c5a6f..cafca6b15 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -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 { @@ -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', diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 67af37216..8d233ea27 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -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' @@ -33,6 +33,7 @@ import { import { Suspense } from './streaming' export { + reactAPICompatVersion as version, jsx, memo, Fragment, @@ -68,6 +69,7 @@ export { } export default { + version: reactAPICompatVersion, memo, Fragment, isValidElement,