Skip to content

Commit 61e165c

Browse files
yusukebekibertoadshellhakiCherryusualoma
authored
next (#5154)
* perf(hono-base): avoid rest parameter in `fetch` (#5113) * perf(context): iterate the header record with for..in (#5118) * perf(url): replace regex tests with indexOf (#5121) * perf(context): skip Headers creation when there are no headers to merge (#5122) * perf(urls): refactor `tryDecodeURIComponent` (#5158) * chore(benchmarks): correct src path on Windows, add json and middleware cases (#5173) The default src path was computed with `URL.pathname`, which yields `/C:/sources/hono/src` on Windows and, after `pathToFileURL`, the broken path `C:\C:\sources\hono\src`. `fileURLToPath` is correct on all platforms. Also adds two cases: - `json GET /user` isolates the `c.json()` response path, which was previously only measured inside `body POST /json` mixed with request body parsing. - `middleware GET /mw/hello` covers the multi-handler `compose` dispatch path, which none of the existing cases exercised. * perf(context): drop the throwaway `env` field initializer (#5174) `env: E['Bindings'] = {}` allocated an object on every `Context` construction that `#dispatch` immediately overwrote with the real env, so the allocation was wasted on every request. The initializer only ever mattered for `new Context(req)` without options, which now gets its `{}` from the constructor's `else` branch. Semantics are unchanged, including `env` being `undefined` when `options.env` is `undefined`. * perf(request): allocate `#validatedData` lazily (#5175) The constructor allocated `#validatedData = {}` on every `HonoRequest`, so every request paid for the validator feature whether or not any validator ran. It is now created on the first `addValidatedData()` call. `valid()` reads through optional chaining and still returns `undefined` for targets that were never validated, as before. * perf(request): probe the body cache without allocating (#5176) `#cachedBody` used `Object.keys(bodyCache)[0]` to find any already cached body, allocating a fresh array on every body read (`req.json()`, `req.text()`, ...) just to read one element. A `for ... in` loop that returns on its first iteration gives the same first-key-by-insertion-order result with no allocation. `bodyCache` is a plain object literal, so there are no enumerable prototype keys to consider. * chore(benchmarks): stabilize measurements by forcing mitata batching (#5183) * perf(hono-base): restore the rest parameter in `fetch` (#5184) * perf(context): restore the `env` field initializer (#5186) * feat: add first-class QUERY method support (#5070) * feat(etag): support conditional requests for the QUERY method (#5111) * feat(cors): allow QUERY by default as a first-class method (#5115) * feat(cache): add first-class support for QUERY requests (#5119) * feat(cache): support QUERY requests * fix(cache): report unavailable QUERY hashing * fix(cache): include request method in cache keys * docs(cache): show QUERY-compatible registration * feat(jsx): add React-compatible overloads to useRef (#5063) * feat(jsx): add React-compatible overloads to useRef Adds overloads so that useRef returns a non-nullable ref when given a non-null initial value, matching React's API. - useRef<T>(initialValue: T): MutableRefObject<T> - useRef<T>(initialValue: T | null): RefObject<T> - useRef<T = undefined>(): MutableRefObject<T | undefined> This is a type-only change; the runtime implementation is unchanged. Closes #5056 * feat(jsx)!: align RefObject and useRef with React 19 - `RefObject<T>` now has non-null mutable `current` - `MutableRefObject<T>` becomes a deprecated alias - `useRef` overloads match React 19; zero-arg form removed in favor of `useRef(undefined)` - `createRef`, `forwardRef`, `useImperativeHandle` updated to use `RefObject<T | null>` for nullable refs BREAKING CHANGE: `RefObject<T>` was `{ current: T | null }`; nullable refs should now be typed as `RefObject<T | null>`. `useRef()` with no argument no longer type-checks; pass `undefined` explicitly. * ci: apply automated fixes * refactor(jsx): remove MutableRefObject alias hono/jsx has never exposed MutableRefObject before, and React 19 marks it as deprecated. Per PR discussion, drop the alias entirely instead of keeping a deprecated re-export. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * feat(middleware): add method-not-allowed middleware (#5132) * feat(middleware): add method-not-allowed * test(middleware): cover method-not-allowed * chore(exports): expose method-not-allowed Resolve #4633 * feat(jwt,jwk): add a configurable WWW-Authenticate realm (#5141) * feat(jwt): add a realm option and escape challenge values The 401 challenge hardcoded `ctx.req.url` as the `realm`. RFC 6750 describes realm as a stable, human-readable name for the protection space, so reflecting the full URL produces a different realm for every path and query string. bearer-auth already exposes a `realm` option; jwt had no equivalent and its `unauthorizedResponse()` helper is private, leaving no way to override it. `realm` and `error_description` are also interpolated into a quoted-string without escaping, so a `"` in either truncates the header. Both are now escaped the way bearer-auth does it. The default is unchanged to stay backward compatible. Refs #4989 * feat(jwk): add a realm option and escape challenge values Mirrors the preceding jwt change; jwk carries an identical copy of `unauthorizedResponse()` with the same hardcoded realm and unescaped interpolation. Refs #4989 * test(jwt,jwk): cover realm configuration and quote escaping Asserts the configured realm reaches the WWW-Authenticate header for both the missing-credentials and invalid-token paths, that embedded double quotes are escaped, and that the default is still the request URL. The realm and escaping cases fail without the preceding commits; the default cases guard against regressing existing behavior. * add a test --------- Co-authored-by: Yusuke Wada <yusuke@kamawada.com> * feat(utils/headers): add HTTP fields newly registered with IANA (#5153) * fix(jsx): allow a function component to return an array (#5179) * fix(jsx): allow a function component to return an array A function component returning an array threw "str.search is not a function" during server-side rendering. The return value did not match any branch in JSXFunctionNode.toStringToBuffer() and fell through to escapeToBuffer(), which expects a string. Handle arrays with childrenToStringToBuffer(), the same path already used for array children. JSX.ElementType is also added so that the type layer accepts components returning any renderable value, matching what hono/jsx/dom already supports at runtime. * fix(jsx): accept an array return in the type layer The runtime already renders a function component that returns an array, but the type layer still rejected it. Add `Child[]` to the call signature of `FC`, and define `JSX.ElementType` with the same return type. Without `JSX.ElementType`, TypeScript checks the return type against `JSX.Element` and reports TS2786. Both types now accept exactly the same set of return values, so nothing beyond arrays becomes newly valid. * fix(jsx): render an array resolved from a promise An async function component returning an array produced escaped, comma-joined text instead of markup, because the resolved array reached the string buffer as-is and was stringified by `String()`. Wrap a resolved array in a fragment so it is rendered like any other node. `Child` now carries `Promise<string | Child[]>`, which surfaced the same hole for children: a child promise resolving to an array took the same path. Both call sites share `resolveArrayToFragment()`, which also attaches the suspended context, so the promise branch of `JSXFunctionNode` no longer needs its own `then()` chain. * fix(jsx): scope promised arrays to component results * fix(jsx-renderer): accept array component results * fix(jsx): preserve callbacks in component arrays * refactor(jsx): remove obsolete suspended context state --------- Co-authored-by: Taku Amano <taku@taaas.jp> * feat(reg-exp-router): throw UnsupportedPathError during route registration (#5171) * fix(reg-exp-router): make wildcard sibling order in the regexp deterministic compareKey() was inconsistent when comparing the only wildcard with the tail wildcard, so their order relied on the insertion order normalized by the path-length sort in the matcher build. * fix(reg-exp-router): capture params on a label node created by an unnamed wildcard A param like /w/:id/y registered together with /w/*/x reused the label node created by the unnamed wildcard, which has no var index assigned, so the param value was lost. * feat(reg-exp-router): throw UnsupportedPathError when adding routes Insert paths into per-method tries incrementally in add() so that unsupported path combinations are detected at registration time instead of at the first match. - Static paths are inserted as real nodes (character by character, with no pattern interpretation), but branches without a dynamic terminal are excluded from the regexp, so the generated matchers stay identical to the previous implementation. - Conflict checks no longer depend on the insertion order: wildcard nodes coexist with anything, and a single-character pattern like /:x{a} coexists with single-character literals. - Node#insert is now a loop instead of recursion with array spreads, making registration plus the first match roughly 20% faster. * test(reg-exp-router): cover method-specific path validation * fix(compress): set Vary: Accept-Encoding on negotiated responses (#5137) * fix(compress): set Vary: Accept-Encoding on compressed responses The compress middleware negotiates gzip/deflate from the request's Accept-Encoding header but never advertises Vary: Accept-Encoding on the response. A shared cache can then store the compressed body and serve it to a client that did not send a compatible Accept-Encoding, breaking that client. Add Accept-Encoding to the response's Vary header when compressing, appending to any existing Vary value and avoiding duplicates. * fix(compress): set Vary: Accept-Encoding on identity responses too Move the Vary handling ahead of the encoding negotiation so that a response left uncompressed — because Accept-Encoding was absent or offered nothing the middleware supports — is also marked as varying on Accept-Encoding. RFC 9110 lists Accept-Encoding "or lack thereof" as a determining factor, so without this a shared cache can reuse the identity response for a client that does accept gzip. The helper now takes the Context and writes through ctx.header(), which recreates the response when a handler returned a fetch() response. On Node that recreation inherits the immutable header guard from the original response, so the write can still throw; fall back to rebuilding the response around a fresh Headers instance in that case. * refactored * remove the node.js test * ci: apply automated fixes --------- Co-authored-by: Yusuke Wada <yusuke@kamawada.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --------- Co-authored-by: Igor Savin <iselwin@gmail.com> Co-authored-by: haki <165590443+shellhaki@users.noreply.github.com> Co-authored-by: James Ross <james@jross.me> Co-authored-by: Taku Amano <taku@taaas.jp> Co-authored-by: asahi <166529527+ashunar0@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Arham Amin <132888838+arhxam@users.noreply.github.com> Co-authored-by: Hiroki Akahoshi <64849251+akahoshi1421@users.noreply.github.com> Co-authored-by: natsuki ueda <63272932+natsuki-engr@users.noreply.github.com>
1 parent 734755a commit 61e165c

41 files changed

Lines changed: 2066 additions & 353 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

benchmarks/fetch/bench.mts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
// HONO_SRC ... path to the hono `src` directory to benchmark (default: ../../src)
1212
// HONO_LABEL ... label used in the output (default: hono)
1313
// HONO_JSON=1 ... suppress mitata output and print a single JSON line instead
14+
// HONO_CASE ... run only cases whose name starts with this prefix (e.g. "ping")
1415
//
1516
// Runs on both Bun and Node.
1617
import './ts-resolve.mjs'
17-
import { run, bench } from 'mitata'
18-
import { pathToFileURL } from 'node:url'
18+
import { run, bench, measure } from 'mitata'
19+
import { fileURLToPath, pathToFileURL } from 'node:url'
1920

20-
const src = process.env.HONO_SRC ?? new URL('../../src', import.meta.url).pathname
21+
const src = process.env.HONO_SRC ?? fileURLToPath(new URL('../../src', import.meta.url))
2122
const label = process.env.HONO_LABEL ?? 'hono'
2223
const asJson = process.env.HONO_JSON === '1'
2324

@@ -37,20 +38,30 @@ const makeApp = async (src: string): Promise<any> => {
3738
c.header('x-powered-by', 'benchmark')
3839
return c.text(`${id} ${name}`)
3940
})
41+
.get('/user', (c: any) => c.json({ id: 123, name: 'Alice', roles: ['admin', 'editor'] }))
42+
.use('/mw/*', async (_c: any, next: any) => {
43+
await next()
44+
})
45+
.get('/mw/hello', (c: any) => c.text('mw'))
4046
return app
4147
}
4248

4349
const app = await makeApp(src)
4450

4551
const ping = new Request('http://localhost/')
4652
const query = new Request('http://localhost/id/1?name=bun')
53+
const user = new Request('http://localhost/user')
54+
const mw = new Request('http://localhost/mw/hello')
4755

4856
let sink: unknown
4957

50-
51-
const cases: [string, (app: any) => Promise<unknown>][] = [
58+
const caseFilter = process.env.HONO_CASE
59+
60+
const allCases: [string, (app: any) => Promise<unknown>][] = [
5261
['ping GET /', (app) => app.fetch(ping)],
5362
['query GET /id/1?name=bun', (app) => app.fetch(query)],
63+
['json GET /user', (app) => app.fetch(user)],
64+
['middleware GET /mw/hello', (app) => app.fetch(mw)],
5465
[
5566
'body POST /json',
5667
(app) =>
@@ -64,21 +75,39 @@ const cases: [string, (app: any) => Promise<unknown>][] = [
6475
],
6576
]
6677

78+
const cases = caseFilter ? allCases.filter(([name]) => name.startsWith(caseFilter)) : allCases
79+
if (cases.length === 0) {
80+
throw new Error(`no cases match HONO_CASE=${caseFilter}`)
81+
}
82+
83+
// Warm up before registering benches: the first fetch builds the router
84+
// lazily (>500µs), which makes mitata skip batching for the first bench.
85+
for (const [, fn] of allCases) {
86+
sink = await fn(app)
87+
}
88+
6789
for (const [name, fn] of cases) {
6890
bench(name, async () => {
6991
sink = await fn(app)
7092
})
7193
}
7294

7395
if (asJson) {
74-
const { benchmarks } = await run({ format: 'quiet' })
96+
// Force batching on: without it, each iteration is timed individually at
97+
// timer granularity (~41ns on Apple Silicon), which is too coarse here.
7598
const results: Record<string, { avg: number; min: number; p75: number }> = {}
76-
for (const b of benchmarks) {
77-
const { stats, error } = b.runs[0]
78-
if (error || !stats) {
79-
throw error ?? new Error(`no stats for ${b.alias}`)
80-
}
81-
results[b.alias] = { avg: stats.avg, min: stats.min, p75: stats.p75 }
99+
for (const [name, fn] of cases) {
100+
const stats = await measure(
101+
async () => {
102+
sink = await fn(app)
103+
},
104+
{
105+
warmup_threshold: Number.MAX_SAFE_INTEGER,
106+
batch_threshold: Number.MAX_SAFE_INTEGER,
107+
}
108+
)
109+
// p50 rather than avg: insensitive to the slow JIT tier-up windows
110+
results[name] = { avg: stats.p50, min: stats.min, p75: stats.p75 }
82111
}
83112
console.log(JSON.stringify({ label, cases: results }))
84113
console.error(typeof sink)

jsr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"./timeout": "./src/middleware/timeout/index.ts",
4646
"./timing": "./src/middleware/timing/timing.ts",
4747
"./logger": "./src/middleware/logger/index.ts",
48+
"./method-not-allowed": "./src/middleware/method-not-allowed/index.ts",
4849
"./method-override": "./src/middleware/method-override/index.ts",
4950
"./powered-by": "./src/middleware/powered-by/index.ts",
5051
"./pretty-json": "./src/middleware/pretty-json/index.ts",

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@
231231
"import": "./dist/middleware/logger/index.js",
232232
"require": "./dist/cjs/middleware/logger/index.js"
233233
},
234+
"./method-not-allowed": {
235+
"types": "./dist/types/middleware/method-not-allowed/index.d.ts",
236+
"import": "./dist/middleware/method-not-allowed/index.js",
237+
"require": "./dist/cjs/middleware/method-not-allowed/index.js"
238+
},
234239
"./method-override": {
235240
"types": "./dist/types/middleware/method-override/index.d.ts",
236241
"import": "./dist/middleware/method-override/index.js",
@@ -528,6 +533,9 @@
528533
"logger": [
529534
"./dist/types/middleware/logger"
530535
],
536+
"method-not-allowed": [
537+
"./dist/types/middleware/method-not-allowed"
538+
],
531539
"method-override": [
532540
"./dist/types/middleware/method-override"
533541
],

src/client/types.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ describe('app.all()', () => {
117117
expectTypeOf(client['all-route']).toHaveProperty('$delete')
118118
expectTypeOf(client['all-route']).toHaveProperty('$options')
119119
expectTypeOf(client['all-route']).toHaveProperty('$patch')
120+
expectTypeOf(client['all-route']).toHaveProperty('$query')
120121
})
121122

122123
it('should have correct return type for expanded methods', async () => {

src/client/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type MethodNameAll = `$${typeof METHOD_NAME_ALL_LOWERCASE}`
1212

1313
/**
1414
* Type representing all standard HTTP methods prefixed with '$'
15-
* e.g., '$get' | '$post' | '$put' | '$delete' | '$options' | '$patch'
15+
* e.g., '$get' | '$post' | '$put' | '$delete' | '$options' | '$patch' | '$query'
1616
*/
1717
type StandardMethods = `$${(typeof METHODS)[number]}`
1818

src/context.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -606,14 +606,12 @@ export class Context<
606606
arg?: StatusCode | ResponseOrInit,
607607
headers?: HeaderRecord
608608
): Response {
609-
const responseHeaders = this.#res
610-
? new Headers(this.#res.headers)
611-
: (this.#preparedHeaders ?? new Headers())
612-
613-
if (typeof arg === 'object' && 'headers' in arg) {
614-
const argHeaders = arg.headers instanceof Headers ? arg.headers : new Headers(arg.headers)
615-
for (const [key, value] of argHeaders) {
616-
if (key.toLowerCase() === 'set-cookie') {
609+
let responseHeaders = this.#res ? new Headers(this.#res.headers) : this.#preparedHeaders
610+
611+
if (typeof arg === 'object' && arg.headers) {
612+
responseHeaders ??= new Headers()
613+
for (const [key, value] of new Headers(arg.headers)) {
614+
if (key === 'set-cookie') {
617615
responseHeaders.append(key, value)
618616
} else {
619617
responseHeaders.set(key, value)
@@ -622,20 +620,35 @@ export class Context<
622620
}
623621

624622
if (headers) {
625-
for (const [k, v] of Object.entries(headers)) {
626-
if (typeof v === 'string') {
627-
responseHeaders.set(k, v)
628-
} else {
629-
responseHeaders.delete(k)
630-
for (const v2 of v) {
631-
responseHeaders.append(k, v2)
623+
if (!responseHeaders) {
624+
let count = 0
625+
for (const k in headers) {
626+
if (++count > 1 || typeof headers[k as keyof HeaderRecord] !== 'string') {
627+
responseHeaders = new Headers()
628+
break
629+
}
630+
}
631+
}
632+
if (responseHeaders) {
633+
for (const k in headers) {
634+
const v = headers[k as keyof HeaderRecord]
635+
if (typeof v === 'string') {
636+
responseHeaders.set(k, v)
637+
} else {
638+
responseHeaders.delete(k)
639+
for (const v2 of v) {
640+
responseHeaders.append(k, v2)
641+
}
632642
}
633643
}
634644
}
635645
}
636646

637647
const status = typeof arg === 'number' ? arg : (arg?.status ?? this.#status)
638-
return createResponseInstance(data, { status, headers: responseHeaders })
648+
return createResponseInstance(data, {
649+
status,
650+
headers: responseHeaders ?? (headers as Record<string, string> | undefined),
651+
})
639652
}
640653

641654
newResponse: NewResponse = (...args) => this.#newResponse(...(args as Parameters<NewResponse>))

src/hono-base.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class Hono<
107107
delete!: HandlerInterface<E, 'delete', S, BasePath, CurrentPath>
108108
options!: HandlerInterface<E, 'options', S, BasePath, CurrentPath>
109109
patch!: HandlerInterface<E, 'patch', S, BasePath, CurrentPath>
110+
query!: HandlerInterface<E, 'query', S, BasePath, CurrentPath>
110111
all!: HandlerInterface<E, 'all', S, BasePath, CurrentPath>
111112
on: OnHandlerInterface<E, S, BasePath>
112113
use: MiddlewareHandlerInterface<E, S, BasePath>
@@ -471,14 +472,14 @@ class Hono<
471472
* @see {@link https://hono.dev/docs/api/hono#fetch}
472473
*
473474
* @param {Request} request - request Object of request
474-
* @param {Env} Env - env Object
475-
* @param {ExecutionContext} - context of execution
475+
* @param {Env} env - env Object
476+
* @param {ExecutionContext} executionCtx - context of execution
476477
* @returns {Response | Promise<Response>} response of request
477478
*
478479
*/
479480
fetch: (
480481
request: Request,
481-
Env?: E['Bindings'] | {},
482+
env?: E['Bindings'] | {},
482483
executionCtx?: ExecutionContext
483484
) => Response | Promise<Response> = (request, ...rest) => {
484485
return this.#dispatch(request, rest[1], rest[0], request.method)

src/hono.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2079,6 +2079,47 @@ describe('Multiple methods with `app.on`', () => {
20792079
})
20802080
})
20812081

2082+
describe('Using QUERY with `app.query` and `app.on`', () => {
2083+
it('Should handle QUERY method with app.query()', async () => {
2084+
const app = new Hono()
2085+
2086+
app.query('/query', (c) => c.text('Accepted', 202))
2087+
2088+
const req = new Request('http://localhost/query', {
2089+
method: 'QUERY',
2090+
})
2091+
const res = await app.request(req)
2092+
expect(res.status).toBe(202)
2093+
expect(await res.text()).toBe('Accepted')
2094+
})
2095+
2096+
it('Should handle QUERY method with RegExpRouter', async () => {
2097+
const app = new Hono({ router: new RegExpRouter() })
2098+
2099+
app.on('QUERY', '/query', (c) => c.text('Accepted', 202))
2100+
2101+
const req = new Request('http://localhost/query', {
2102+
method: 'QUERY',
2103+
})
2104+
const res = await app.request(req)
2105+
expect(res.status).toBe(202)
2106+
expect(await res.text()).toBe('Accepted')
2107+
})
2108+
2109+
it('Should handle QUERY method with TrieRouter', async () => {
2110+
const app = new Hono({ router: new TrieRouter() })
2111+
2112+
app.on('QUERY', '/query', (c) => c.text('Accepted', 202))
2113+
2114+
const req = new Request('http://localhost/query', {
2115+
method: 'QUERY',
2116+
})
2117+
const res = await app.request(req)
2118+
expect(res.status).toBe(202)
2119+
expect(await res.text()).toBe('Accepted')
2120+
})
2121+
})
2122+
20822123
describe('Multiple paths with one handler', () => {
20832124
const app = new Hono()
20842125

src/jsx/base.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,13 @@ import {
2525

2626
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2727
export type Props = Record<string, any>
28+
type FunctionComponentResult =
29+
| HtmlEscapedString
30+
| Child[]
31+
| Promise<HtmlEscapedString | Child[]>
32+
| null
2833
export type FC<P = Props> = {
29-
(props: P): HtmlEscapedString | Promise<HtmlEscapedString> | null
34+
(props: P): FunctionComponentResult
3035
defaultProps?: Partial<P> | undefined
3136
displayName?: string | undefined
3237
}
@@ -35,6 +40,7 @@ export type DOMAttributes = HonoJSX.HTMLAttributes
3540
// eslint-disable-next-line @typescript-eslint/no-namespace
3641
export namespace JSX {
3742
export type Element = HtmlEscapedString | Promise<HtmlEscapedString>
43+
export type ElementType = string | ((props: never) => FunctionComponentResult)
3844
export interface ElementChildrenAttribute {
3945
children: Child
4046
}
@@ -105,6 +111,27 @@ export const booleanAttributes = [
105111
'selected',
106112
]
107113

114+
type SuspendedContext = <T>(callback: () => T) => T
115+
116+
const resolveFunctionComponentResult = (
117+
result: Promise<string | JSXNode | Child[]>,
118+
suspendedContext?: SuspendedContext
119+
): Promise<string> =>
120+
result.then((resolved) => {
121+
if (!Array.isArray(resolved) && !(resolved instanceof JSXNode)) {
122+
return resolved
123+
}
124+
const children = Array.isArray(resolved) ? resolved : [resolved]
125+
const render = () => {
126+
const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks
127+
childrenToStringToBuffer(children, buffer)
128+
return buffer.length === 1
129+
? raw(buffer[0], buffer.callbacks)
130+
: stringBufferToString(buffer, buffer.callbacks)
131+
}
132+
return suspendedContext ? suspendedContext(render) : runWithRenderContext(render)
133+
})
134+
108135
const childrenToStringToBuffer = (children: Child[], buffer: StringBufferWithCallbacks): void => {
109136
for (let i = 0, len = children.length; i < len; i++) {
110137
const child = children[i]
@@ -114,11 +141,15 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBufferWithCal
114141
continue
115142
} else if (child instanceof JSXNode) {
116143
child.toStringToBuffer(buffer)
117-
} else if (
118-
typeof child === 'number' ||
119-
(child as unknown as { isEscaped: boolean }).isEscaped
120-
) {
144+
} else if (typeof child === 'number') {
121145
;(buffer[0] as string) += child
146+
} else if ((child as unknown as HtmlEscaped).isEscaped) {
147+
;(buffer[0] as string) += child
148+
const callbacks = (child as unknown as HtmlEscapedString).callbacks
149+
if (callbacks) {
150+
buffer.callbacks ||= []
151+
buffer.callbacks.push(...callbacks)
152+
}
122153
} else if (child instanceof Promise) {
123154
buffer.unshift('', child)
124155
} else {
@@ -143,7 +174,6 @@ export class JSXNode implements HtmlEscaped {
143174
key?: string
144175
children: Child[]
145176
isEscaped: true = true as const
146-
suspendedContext?: <T>(callback: () => T) => T
147177
constructor(tag: string | Function, props: Props, children: Child[]) {
148178
if (typeof tag !== 'function' && !isValidTagName(tag)) {
149179
throw new Error(`Invalid JSX tag name: ${tag}`)
@@ -173,7 +203,7 @@ export class JSXNode implements HtmlEscaped {
173203
: buffer[0]
174204
: stringBufferToString(buffer, buffer.callbacks)
175205
}
176-
return this.suspendedContext ? this.suspendedContext(render) : runWithRenderContext(render)
206+
return runWithRenderContext(render)
177207
}
178208

179209
toStringToBuffer(buffer: StringBufferWithCallbacks): void {
@@ -267,22 +297,15 @@ class JSXFunctionNode extends JSXNode {
267297
return
268298
} else if (res instanceof Promise) {
269299
if (globalContexts.length === 0) {
270-
buffer.unshift('', res)
300+
buffer.unshift('', resolveFunctionComponentResult(res))
271301
} else {
272302
// save the current context state for resuming the suspended subtree
273-
const suspendedContext = captureRenderContext()
274-
buffer.unshift(
275-
'',
276-
res.then((childRes) => {
277-
if (childRes instanceof JSXNode) {
278-
childRes.suspendedContext = suspendedContext
279-
}
280-
return childRes
281-
})
282-
)
303+
buffer.unshift('', resolveFunctionComponentResult(res, captureRenderContext()))
283304
}
284305
} else if (res instanceof JSXNode) {
285306
res.toStringToBuffer(buffer)
307+
} else if (Array.isArray(res)) {
308+
childrenToStringToBuffer(res, buffer)
286309
} else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) {
287310
buffer[0] += res
288311
if (res.callbacks) {

0 commit comments

Comments
 (0)