Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React 19 compat #3048

Merged
merged 3 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion runtime_tests/deno-jsx/deno.precompile.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"jsxImportSource": "hono/jsx",
"lib": [
"deno.ns",
"dom"
"dom",
"dom.iterable"
]
},
"unstable": [
Expand Down
3 changes: 2 additions & 1 deletion runtime_tests/deno-jsx/deno.react-jsx.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"jsxImportSource": "hono/jsx",
"lib": [
"deno.ns",
"dom"
"dom",
"dom.iterable"
]
},
"unstable": [
Expand Down
12 changes: 8 additions & 4 deletions src/helper/html/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
* html Helper for Hono.
*/

import { escapeToBuffer, raw, stringBufferToString } from '../../utils/html'
import type { HtmlEscaped, HtmlEscapedString, StringBuffer } from '../../utils/html'
import { escapeToBuffer, raw, resolveCallbackSync, stringBufferToString } from '../../utils/html'
import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from '../../utils/html'

export { raw }

export const html = (
strings: TemplateStringsArray,
...values: unknown[]
): HtmlEscapedString | Promise<HtmlEscapedString> => {
const buffer: StringBuffer = ['']
const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks

for (let i = 0, len = strings.length - 1; i < len; i++) {
buffer[0] += strings[i]
Expand Down Expand Up @@ -48,5 +48,9 @@
}
buffer[0] += strings[strings.length - 1]

return buffer.length === 1 ? raw(buffer[0]) : stringBufferToString(buffer)
return buffer.length === 1
? 'callbacks' in buffer
? raw(resolveCallbackSync(raw(buffer[0], buffer.callbacks)))

Check warning on line 53 in src/helper/html/index.ts

View check run for this annotation

Codecov / codecov/patch

src/helper/html/index.ts#L53

Added line #L53 was not covered by tests
: raw(buffer[0])
: stringBufferToString(buffer, buffer.callbacks)
}
45 changes: 36 additions & 9 deletions src/jsx/base.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { raw } from '../helper/html'
import { escapeToBuffer, stringBufferToString } from '../utils/html'
import type { HtmlEscaped, HtmlEscapedString, StringBuffer } from '../utils/html'
import { escapeToBuffer, resolveCallbackSync, stringBufferToString } from '../utils/html'
import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from '../utils/html'
import type { Context } from './context'
import { globalContexts } from './context'
import { DOM_RENDERER } from './constants'
import type {
JSX as HonoJSX,
IntrinsicElements as IntrinsicElementsDefined,
} from './intrinsic-elements'
import { normalizeIntrinsicElementKey, styleObjectForEach } from './utils'
import * as intrinsicElementTags from './intrinsic-element/components'
import { domRenderers } from './intrinsic-element/common'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Props = Record<string, any>
Expand Down Expand Up @@ -74,7 +77,7 @@ const booleanAttributes = [
'selected',
]

const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void => {
const childrenToStringToBuffer = (children: Child[], buffer: StringBufferWithCallbacks): void => {
for (let i = 0, len = children.length; i < len; i++) {
const child = children[i]
if (typeof child === 'string') {
Expand Down Expand Up @@ -131,7 +134,7 @@ export class JSXNode implements HtmlEscaped {
}

toString(): string | Promise<string> {
const buffer: StringBuffer = ['']
const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks
this.localContexts?.forEach(([context, value]) => {
context.values.push(value)
})
Expand All @@ -142,10 +145,14 @@ export class JSXNode implements HtmlEscaped {
context.values.pop()
})
}
return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer)
return buffer.length === 1
? 'callbacks' in buffer
? resolveCallbackSync(raw(buffer[0], buffer.callbacks)).toString()
: buffer[0]
: stringBufferToString(buffer, buffer.callbacks)
}

toStringToBuffer(buffer: StringBuffer): void {
toStringToBuffer(buffer: StringBufferWithCallbacks): void {
const tag = this.tag as string
const props = this.props
let { children } = this
Expand Down Expand Up @@ -214,7 +221,7 @@ export class JSXNode implements HtmlEscaped {
}

class JSXFunctionNode extends JSXNode {
toStringToBuffer(buffer: StringBuffer): void {
toStringToBuffer(buffer: StringBufferWithCallbacks): void {
const { children } = this

const res = (this.tag as Function).call(null, {
Expand Down Expand Up @@ -242,14 +249,18 @@ class JSXFunctionNode extends JSXNode {
res.toStringToBuffer(buffer)
} else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) {
buffer[0] += res
if (res.callbacks) {
buffer.callbacks ||= []
buffer.callbacks.push(...res.callbacks)
}
} else {
escapeToBuffer(res, buffer)
}
}
}

export class JSXFragmentNode extends JSXNode {
toStringToBuffer(buffer: StringBuffer): void {
toStringToBuffer(buffer: StringBufferWithCallbacks): void {
childrenToStringToBuffer(this.children, buffer)
}
}
Expand All @@ -272,13 +283,29 @@ export const jsx = (
return node
}

let initDomRenderer = false
export const jsxFn = (
tag: string | Function,
props: Props,
children: (string | number | HtmlEscapedString)[]
): JSXNode => {
if (!initDomRenderer) {
for (const k in domRenderers) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(intrinsicElementTags[k as keyof typeof intrinsicElementTags] as any)[DOM_RENDERER] =
domRenderers[k]
}
initDomRenderer = true
}

if (typeof tag === 'function') {
return new JSXFunctionNode(tag, props, children)
} else if (intrinsicElementTags[tag as keyof typeof intrinsicElementTags]) {
return new JSXFunctionNode(
intrinsicElementTags[tag as keyof typeof intrinsicElementTags],
props,
children
)
} else {
return new JSXNode(tag, props, children)
}
Expand Down Expand Up @@ -357,4 +384,4 @@ export const cloneElement = <T extends JSXNode | JSX.Element>(
) as T
}

export const reactAPICompatVersion = '18.0.0-hono-jsx'
export const reactAPICompatVersion = '19.0.0-hono-jsx'
1 change: 1 addition & 0 deletions src/jsx/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export const DOM_RENDERER = Symbol('RENDERER')
export const DOM_ERROR_HANDLER = Symbol('ERROR_HANDLER')
export const DOM_STASH = Symbol('STASH')
export const DOM_INTERNAL_TAG = Symbol('INTERNAL')
export const PERMALINK = Symbol('PERMALINK')
52 changes: 25 additions & 27 deletions src/jsx/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { createContextProviderFunction } from './dom/context'
import type { FC, PropsWithChildren } from './'

export interface Context<T> {
export interface Context<T> extends FC<PropsWithChildren<{ value: T }>> {
values: T[]
Provider: FC<PropsWithChildren<{ value: T }>>
}
Expand All @@ -14,33 +14,31 @@

export const createContext = <T>(defaultValue: T): Context<T> => {
const values = [defaultValue]
const context: Context<T> = {
values,
Provider(props): HtmlEscapedString | Promise<HtmlEscapedString> {
values.push(props.value)
let string
try {
string = props.children
? (Array.isArray(props.children)
? new JSXFragmentNode('', {}, props.children)
: props.children
).toString()
: ''
} finally {
values.pop()
}

if (string instanceof Promise) {
return string.then((resString) =>
raw(resString, (resString as HtmlEscapedString).callbacks)
)
} else {
return raw(string)
}
},
}
const context: Context<T> = ((props): HtmlEscapedString | Promise<HtmlEscapedString> => {
values.push(props.value)
let string
try {
string = props.children
? (Array.isArray(props.children)
? new JSXFragmentNode('', {}, props.children)
: props.children
).toString()
: ''

Check warning on line 26 in src/jsx/context.ts

View check run for this annotation

Codecov / codecov/patch

src/jsx/context.ts#L26

Added line #L26 was not covered by tests
} finally {
values.pop()
}

if (string instanceof Promise) {
return string.then((resString) => raw(resString, (resString as HtmlEscapedString).callbacks))
} else {
return raw(string)
}
}) as Context<T>
context.values = values
context.Provider = context

// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(context.Provider as any)[DOM_RENDERER] = createContextProviderFunction(values)
;(context as any)[DOM_RENDERER] = createContextProviderFunction(values)

globalContexts.push(context as Context<unknown>)

Expand Down
109 changes: 109 additions & 0 deletions src/jsx/dom/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,40 @@ function runner(
expect(root.innerHTML).toBe('<p>1</p>')
})

it('with use() update', async () => {
const counterMap: Record<number, Promise<number>> = {}
const getCounter = (count: number) => (counterMap[count] ||= Promise.resolve(count + 1))
const Content = ({ count }: { count: number }) => {
const num = use(getCounter(count))
return (
<>
<div>{num}</div>
</>
)
}
const Component = () => {
const [count, setCount] = useState(0)
return (
<Suspense fallback={<div>Loading...</div>}>
<Content count={count} />
<button onClick={() => setCount(count + 1)}>Increment</button>
</Suspense>
)
}
const App = <Component />
render(App, root)
expect(root.innerHTML).toBe('<div>Loading...</div>')
await Promise.resolve()
await Promise.resolve()
expect(root.innerHTML).toBe('<div>1</div><button>Increment</button>')
root.querySelector('button')?.click()
await Promise.resolve()
expect(root.innerHTML).toBe('<div>Loading...</div>')
await Promise.resolve()
await Promise.resolve()
expect(root.innerHTML).toBe('<div>2</div><button>Increment</button>')
})

it('with use() nested', async () => {
let resolve: (value: number) => void = () => {}
const promise = new Promise<number>((_resolve) => (resolve = _resolve))
Expand Down Expand Up @@ -131,6 +165,81 @@ function runner(
await Promise.resolve()
expect(root.innerHTML).toBe('<div><button>Hide</button><p>2</p></div>')
})

it('Suspense at child', async () => {
let resolve: (value: number) => void = () => {}
const promise = new Promise<number>((_resolve) => (resolve = _resolve))
const Content = () => {
const num = use(promise)
return <p>{num}</p>
}

const Component = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<Content />
</Suspense>
)
}
const App = () => {
const [show, setShow] = useState(false)
return (
<div>
{show && <Component />}
<button onClick={() => setShow(true)}>Show</button>
</div>
)
}
render(<App />, root)
expect(root.innerHTML).toBe('<div><button>Show</button></div>')
root.querySelector('button')?.click()
await Promise.resolve()
expect(root.innerHTML).toBe('<div><div>Loading...</div><button>Show</button></div>')
resolve(2)
await Promise.resolve()
await Promise.resolve()
expect(root.innerHTML).toBe('<div><p>2</p><button>Show</button></div>')
})

it('Suspense at child counter', async () => {
const promiseMap: Record<number, Promise<number>> = {}
const Counter = () => {
const [count, setCount] = useState(0)
const promise = (promiseMap[count] ||= Promise.resolve(count))
const value = use(promise)
return (
<>
<p>{value}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</>
)
}
const Component = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<Counter />
</Suspense>
)
}
const App = () => {
return (
<div>
<Component />
</div>
)
}
render(<App />, root)
expect(root.innerHTML).toBe('<div><div>Loading...</div></div>')
await Promise.resolve()
await Promise.resolve()
expect(root.innerHTML).toBe('<div><p>0</p><button>Increment</button></div>')
root.querySelector('button')?.click()
await Promise.resolve()
expect(root.innerHTML).toBe('<div><div>Loading...</div></div>')
await Promise.resolve()
await Promise.resolve()
expect(root.innerHTML).toBe('<div><p>1</p><button>Increment</button></div>')
})
})

describe('ErrorBoundary', () => {
Expand Down
18 changes: 18 additions & 0 deletions src/jsx/dom/context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ function runner(
expect(root.innerHTML).toBe('<p>1</p>')
})

it('<Context> as a provider ', async () => {
const Context = createContext(0)
const Content = () => {
const num = useContext(Context)
return <p>{num}</p>
}
const Component = () => {
return (
<Context value={1}>
<Content />
</Context>
)
}
const App = <Component />
render(App, root)
expect(root.innerHTML).toBe('<p>1</p>')
})

it('simple context with state', async () => {
const Context = createContext(0)
const Content = () => {
Expand Down
Loading