Skip to content

Commit

Permalink
feat(jsx/server): introduce jsx/dom/server module for compatibility…
Browse files Browse the repository at this point in the history
… with `react-dom/server` (#2930)

* feat(jsx/server): introduce `jsx/dom/server` module for compatibility with `react-dom/server`

* refactor: tweaks signature of onError callback in renderToReadableStream for compatibility

* Add jsx/dom/server to jsr.json

* fix: relative import path error in `deno publish`
  • Loading branch information
usualoma committed Jun 12, 2024
1 parent 668a07c commit c910923
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 1 deletion.
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"./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",
"./jsx/dom/server": "./src/jsx/dom/server.ts",
"./jwt": "./src/middleware/jwt/jwt.ts",
"./timing": "./src/middleware/timing/timing.ts",
"./logger": "./src/middleware/logger/index.ts",
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@
"import": "./dist/jsx/dom/css.js",
"require": "./dist/cjs/jsx/dom/css.js"
},
"./jsx/dom/server": {
"types": "./dist/types/jsx/dom/server.d.ts",
"import": "./dist/jsx/dom/server.js",
"require": "./dist/cjs/jsx/dom/server.js"
},
"./jwt": {
"types": "./dist/types/middleware/jwt/index.d.ts",
"import": "./dist/middleware/jwt/index.js",
Expand Down Expand Up @@ -434,6 +439,9 @@
"jsx/dom/css": [
"./dist/types/jsx/dom/css.d.ts"
],
"jsx/dom/server": [
"./dist/types/jsx/dom/server.d.ts"
],
"jwt": [
"./dist/types/middleware/jwt"
],
Expand Down
128 changes: 128 additions & 0 deletions src/jsx/dom/server.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/** @jsxImportSource ../ */
import { renderToReadableStream, renderToString } from './server'

describe('renderToString', () => {
it('Should be able to render HTML element', () => {
expect(renderToString(<h1>Hello</h1>)).toBe('<h1>Hello</h1>')
})

it('Should be able to render null', () => {
expect(renderToString(null)).toBe('')
})

it('Should be able to render undefined', () => {
expect(renderToString(undefined)).toBe('')
})

it('Should be able to render number', () => {
expect(renderToString(1)).toBe('1')
})

it('Should be able to render string', () => {
expect(renderToString('Hono')).toBe('Hono')
})

it('Should omit options', () => {
expect(renderToString('Hono', { identifierPrefix: 'test' })).toBe('Hono')
})

it('Should raise error for async component', async () => {
const AsyncComponent = async () => <h1>Hello from async component</h1>
expect(() => renderToString(<AsyncComponent />)).toThrowError()
})
})

describe('renderToReadableStream', () => {
const textDecoder = new TextDecoder()
const getStringFromStream = async (stream: ReadableStream<Uint8Array>): Promise<string> => {
const reader = stream.getReader()
let str = ''
for (;;) {
const { done, value } = await reader.read()
if (done) {
break
}
str += textDecoder.decode(value)
}
return str
}

it('Should be able to render HTML element', async () => {
const stream = await renderToReadableStream(<h1>Hello</h1>)
const reader = stream.getReader()
let { done, value } = await reader.read()
expect(done).toBe(false)
expect(textDecoder.decode(value)).toBe('<h1>Hello</h1>')
done = (await reader.read()).done
expect(done).toBe(true)
})

it('Should be able to render null', async () => {
expect(await getStringFromStream(await renderToReadableStream(null))).toBe('')
})

it('Should be able to render undefined', async () => {
expect(await getStringFromStream(await renderToReadableStream(undefined))).toBe('')
})

it('Should be able to render number', async () => {
expect(await getStringFromStream(await renderToReadableStream(1))).toBe('1')
})

it('Should be able to render string', async () => {
expect(await getStringFromStream(await renderToReadableStream('Hono'))).toBe('Hono')
})

it('Should be called `onError` if there is an error', async () => {
const ErrorComponent = async () => {
throw new Error('Server error')
}

const onError = vi.fn()
expect(
await getStringFromStream(await renderToReadableStream(<ErrorComponent />, { onError }))
).toBe('')
expect(onError).toBeCalledWith(new Error('Server error'))
})

it('Should not be called `onError` if there is no error', async () => {
const onError = vi.fn(() => 'error')
expect(await getStringFromStream(await renderToReadableStream('Hono', { onError }))).toBe(
'Hono'
)
expect(onError).toBeCalledTimes(0)
})

it('Should omit options, except onError', async () => {
expect(
await getStringFromStream(await renderToReadableStream('Hono', { identifierPrefix: 'test' }))
).toBe('Hono')
})

it('Should be able to render async component', async () => {
const ChildAsyncComponent = async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return <span>child async component</span>
}

const AsyncComponent = async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return (
<h1>
Hello from async component
<ChildAsyncComponent />
</h1>
)
}

const stream = await renderToReadableStream(<AsyncComponent />)
const reader = stream.getReader()
let { done, value } = await reader.read()
expect(done).toBe(false)
expect(textDecoder.decode(value)).toBe(
'<h1>Hello from async component<span>child async component</span></h1>'
)
done = (await reader.read()).done
expect(done).toBe(true)
})
})
70 changes: 70 additions & 0 deletions src/jsx/dom/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @module
* This module provides APIs for `hono/jsx/server`, which is compatible with `react-dom/server`.
*/

import type { Child } from '../base'
import { renderToReadableStream as renderToReadableStreamHono } from '../streaming'
import version from './'
import type { HtmlEscapedString } from '../../utils/html'

export interface RenderToStringOptions {
identifierPrefix?: string
}

/**
* Render JSX element to string.
* @param element JSX element to render.
* @param options Options for rendering.
* @returns Rendered string.
*/
const renderToString = (element: Child, options: RenderToStringOptions = {}): string => {
if (Object.keys(options).length > 0) {
console.warn('options are not supported yet')
}
const res = element?.toString() ?? ''
if (typeof res !== 'string') {
throw new Error('Async component is not supported in renderToString')
}
return res
}

export interface RenderToReadableStreamOptions {
identifierPrefix?: string
namespaceURI?: string
nonce?: string
bootstrapScriptContent?: string
bootstrapScripts?: string[]
bootstrapModules?: string[]
progressiveChunkSize?: number
signal?: AbortSignal
onError?: (error: unknown) => string | void
}

/**
* Render JSX element to readable stream.
* @param element JSX element to render.
* @param options Options for rendering.
* @returns Rendered readable stream.
*/
const renderToReadableStream = async (
element: Child,
options: RenderToReadableStreamOptions = {}
): Promise<ReadableStream<Uint8Array>> => {
if (Object.keys(options).some((key) => key !== 'onError')) {
console.warn('options are not supported yet, except onError')
}

if (!element || typeof element !== 'object') {
element = element?.toString() ?? ''
}

return renderToReadableStreamHono(element as HtmlEscapedString, options.onError)
}

export { renderToString, renderToReadableStream, version }
export default {
renderToString,
renderToReadableStream,
version,
}
2 changes: 1 addition & 1 deletion src/jsx/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const textEncoder = new TextEncoder()
*/
export const renderToReadableStream = (
str: HtmlEscapedString | Promise<HtmlEscapedString>,
onError: (e: unknown) => void = console.trace
onError: (e: unknown) => string | void = console.trace
): ReadableStream<Uint8Array> => {
const reader = new ReadableStream<Uint8Array>({
async start(controller) {
Expand Down

0 comments on commit c910923

Please sign in to comment.