Skip to content

Commit

Permalink
feat: introduce React Renderer Middleware (#309)
Browse files Browse the repository at this point in the history
* feat: introduce React Renderer Middleware

* docs: update readme

* chore: add changeset

* use `global.d.ts`

* remove global.d.ts

* import types correctly
  • Loading branch information
yusukebe authored Dec 16, 2023
1 parent ee12e3c commit 9348fa2
Show file tree
Hide file tree
Showing 9 changed files with 544 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/twelve-spiders-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/react-renderer': patch
---

feat: introduce React Renderer
224 changes: 224 additions & 0 deletions packages/react-renderer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# React Renderer Middleware

React Renderer Middleware allows for the easy creation of a renderer based on React for Hono.

## Installation

```txt
npm i @hono/react-renderer react react-dom hono
npm i -D @types/react @types/react-dom
```

## Settings

`tsconfig.json`:

```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}
```

## Usage

### Basic

```tsx
import { Hono } from 'hono'
import { reactRenderer } from '@hono/react-renderer'

const app = new Hono()

app.get(
'*',
reactRenderer(({ children }) => {
return (
<html>
<body>
<h1>React + Hono</h1>
<div>{children}</div>
</body>
</html>
)
})
)

app.get('/', (c) => {
return c.render(<p>Welcome!</p>)
})
```

### Extending `Props`

You can define types of `Props`:

```tsx
declare module '@hono/react-renderer' {
interface Props {
title: string
}
}
```

Then, you can use it in the `reactRenderer()` function and pass values as a second argument to `c.render()`:

```tsx
app.get(
'*',
reactRenderer(({ children, title }) => {
return (
<html>
<head>
<title>{title}</title>
</head>
<body>
<div>{children}</div>
</body>
</html>
)
})
)

app.get('/', (c) => {
return c.render(<p>Welcome!</p>, {
title: 'Top Page',
})
})
```

### `useRequestContext()`

You can get an instance of `Context` in a function component:

```tsx
const Component = () => {
const c = useRequestContext()
return <p>You access {c.req.url}</p>
}

app.get('/', (c) => {
return c.render(<Component />)
})
```

## Options

### `docType`

If you set it `true`, `DOCTYPE` will be added:

```tsx
app.get(
'*',
reactRenderer(
({ children }) => {
return (
<html>
<body>
<div>{children}</div>
</body>
</html>
)
},
{
docType: true,
}
)
)
```

The HTML is the following:

```html
<!DOCTYPE html>
<html>
<body>
<div><p>Welcome!</p></div>
</body>
</html>
```

You can specify the `docType` as you like.

```tsx
app.get(
'*',
reactRenderer(
({ children }) => {
return (
<html>
<body>
<div>{children}</div>
</body>
</html>
)
},
{
docType:
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
}
)
)
```

### `stream`

It enables returning a streaming response. You can use a `Suspense` with it:

```tsx
import { reactRenderer } from '@hono/react-renderer'
import { Suspense } from 'react'

app.get(
'*',
reactRenderer(
({ children }) => {
return (
<html>
<body>
<div>{children}</div>
</body>
</html>
)
},
{
stream: true,
}
)
)

let done = false

const Component = () => {
if (done) {
return <p>Done!</p>
}
throw new Promise((resolve) => {
done = true
setTimeout(resolve, 1000)
})
}

app.get('/', (c) => {
return c.render(
<Suspense fallback='loading...'>
<Component />
</Suspense>
)
})
```

## Limitation

A streaming feature is not available on Vite or Vitest.

## Author

Yusuke Wada <https://github.com/yusukebe>

## License

MIT
49 changes: 49 additions & 0 deletions packages/react-renderer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@hono/react-renderer",
"version": "0.0.0",
"description": "React Renderer Middleware for Hono",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "vitest --run",
"build": "tsup ./src/index.ts --external hono,react,react-dom --format esm,cjs --dts",
"publint": "publint"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/honojs/middleware.git"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"hono": "*"
},
"devDependencies": {
"@types/react": "^18",
"@types/react-dom": "^18.2.17",
"hono": "^3.11.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tsup": "^8.0.1",
"vitest": "^1.0.4"
},
"engines": {
"node": ">=18"
}
}
9 changes: 9 additions & 0 deletions packages/react-renderer/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export * from './react-renderer'

export type Props = {}

declare module 'hono' {
interface ContextRenderer {
(children: React.ReactElement, props?: Props): Response | Promise<Response>
}
}
68 changes: 68 additions & 0 deletions packages/react-renderer/src/react-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Context } from 'hono'
import type { Env, MiddlewareHandler } from 'hono/types'
import React from 'react'
import { renderToString, renderToReadableStream } from 'react-dom/server'
import type { Props } from '.'

type RendererOptions = {
docType?: boolean | string
stream?: boolean | Record<string, string>
}

type BaseProps = {
c: Context
children: React.ReactElement
}

const RequestContext = React.createContext<Context | null>(null)

const createRenderer =
(c: Context, component?: React.FC<Props & BaseProps>, options?: RendererOptions) =>
async (children: React.ReactElement, props?: Props) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const node = component ? component({ children, c, ...props }) : children

if (options?.stream) {
const stream = await renderToReadableStream(
React.createElement(RequestContext.Provider, { value: c }, node)
)
return c.body(stream, {
headers:
options.stream === true
? {
'Transfer-Encoding': 'chunked',
'Content-Type': 'text/html; charset=UTF-8',
}
: options.stream,
})
} else {
const docType =
typeof options?.docType === 'string'
? options.docType
: options?.docType === true
? '<!DOCTYPE html>'
: ''
const body =
docType + renderToString(React.createElement(RequestContext.Provider, { value: c }, node))
return c.html(body)
}
}

export const reactRenderer = (
component?: React.FC<Props & BaseProps>,
options?: RendererOptions
): MiddlewareHandler =>
function reactRenderer(c, next) {
c.setRenderer(createRenderer(c, component, options))
return next()
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useRequestContext = <E extends Env = any>(): Context<E> => {
const c = React.useContext(RequestContext)
if (!c) {
throw new Error('RequestContext is not provided.')
}
return c
}
Loading

0 comments on commit 9348fa2

Please sign in to comment.