Merged
Conversation
Bumps [hono](https://github.com/honojs/hono) from 4.12.5 to 4.12.7. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](honojs/hono@v4.12.5...v4.12.7) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com>
Contributor
|
[puLL-Merge] - honojs/hono@v4.12.5..v4.12.7 Diffdiff --git .github/actions/perf-measures/action.yml .github/actions/perf-measures/action.yml
index af56148a76..69e35fca6c 100644
--- .github/actions/perf-measures/action.yml
+++ .github/actions/perf-measures/action.yml
@@ -14,7 +14,9 @@ runs:
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.tool-versions'
- - run: bun install --frozen-lockfile
+ - run: |
+ bun install --frozen-lockfile
+ bun tsc --build
shell: bash
- name: Performance measurement of type check (tsc)
diff --git bun.lock bun.lock
index f407d7eef5..7a8e868672 100644
--- bun.lock
+++ bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "hono",
@@ -9,6 +10,7 @@
"@types/glob": "^9.0.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.3.0",
+ "@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260210.1",
"@vitest/coverage-v8": "^3.2.4",
"arg": "^5.0.2",
@@ -453,6 +455,8 @@
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
diff --git package.json package.json
index ed28517293..a8a4d6ea24 100644
--- package.json
+++ package.json
@@ -1,6 +1,6 @@
{
"name": "hono",
- "version": "4.12.5",
+ "version": "4.12.7",
"description": "Web framework built on Web Standards",
"main": "dist/cjs/index.js",
"type": "module",
@@ -661,6 +661,7 @@
"@types/glob": "^9.0.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.3.0",
+ "@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260210.1",
"@vitest/coverage-v8": "^3.2.4",
"arg": "^5.0.2",
diff --git perf-measures/tsconfig.json perf-measures/tsconfig.json
deleted file mode 100644
index 56adabbfa2..0000000000
--- perf-measures/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../tsconfig.json",
- "compilerOptions": {
- "module": "esnext",
- "noEmit": true,
- "rootDir": "..",
- "strict": true
- },
- "include": ["**/*.ts", "**/*.tsx"]
-}
diff --git a/perf-measures/type-check/scripts/tsconfig.json b/perf-measures/type-check/scripts/tsconfig.json
new file mode 100644
index 0000000000..253b0d5f68
--- /dev/null
+++ perf-measures/type-check/scripts/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "esnext",
+ "noEmit": true
+ }
+}
diff --git perf-measures/type-check/tsconfig.build.json perf-measures/type-check/tsconfig.build.json
index 78ae9c23f0..49377f71fc 100644
--- perf-measures/type-check/tsconfig.build.json
+++ perf-measures/type-check/tsconfig.build.json
@@ -1,7 +1,10 @@
{
- "extends": "../tsconfig.json",
- "include": ["client.ts"],
+ "extends": "../../tsconfig.base.json",
"compilerOptions": {
- "skipLibCheck": true
- }
+ "noEmit": true
+ },
+ "exclude": ["dist", "scripts"],
+ "references": [
+ { "path": "../../tsconfig.build.json" }
+ ]
}
diff --git runtime-tests/bun/index.test.tsx runtime-tests/bun/index.test.tsx
index 63b3e6fc16..46c9b686e5 100644
--- runtime-tests/bun/index.test.tsx
+++ runtime-tests/bun/index.test.tsx
@@ -9,11 +9,16 @@ import { Context } from '../../src/context'
import { env, getRuntimeKey } from '../../src/helper/adapter'
import type { WSMessageReceive } from '../../src/helper/websocket'
import { Hono } from '../../src/index'
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-import { jsx } from '../../src/jsx'
+import type { PropsWithChildren } from '../../src/jsx'
import { basicAuth } from '../../src/middleware/basic-auth'
import { jwt } from '../../src/middleware/jwt'
+declare module '../../src/index' {
+ interface ContextRenderer {
+ (content: string | Promise<string>, head: { title: string }): Response | Promise<Response>
+ }
+}
+
// Test just only minimal patterns.
// Because others are tested well in Cloudflare Workers environment already.
@@ -201,7 +206,7 @@ describe('JWT Auth Middleware', () => {
describe('JSX Middleware', () => {
const app = new Hono()
- const Layout = (props: { children?: string }) => {
+ const Layout = (props: PropsWithChildren) => {
return <html>{props.children}</html>
}
@@ -277,7 +282,7 @@ describe('toSSG function', () => {
it('Should correctly generate static HTML files for Hono routes', async () => {
const result = await toSSG(app, { dir: './static' })
- expect(result.success).toBeTruly
+ expect(result.success).toBeTruthy()
expect(result.error).toBeUndefined()
expect(result.files).toBeDefined()
afterAll(async () => {
@@ -323,7 +328,7 @@ describe('WebSockets Helper', () => {
})
})
-async function deleteDirectory(dirPath) {
+async function deleteDirectory(dirPath: string) {
if (
await fs
.stat(dirPath)
diff --git a/runtime-tests/bun/tsconfig.json b/runtime-tests/bun/tsconfig.json
new file mode 100644
index 0000000000..5394666521
--- /dev/null
+++ runtime-tests/bun/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx",
+ "noEmit": true,
+ "types": ["bun-types"]
+ },
+ "references": [
+ { "path": "../../tsconfig.build.json" }
+ ]
+}
diff --git runtime-tests/fastly/index.test.ts runtime-tests/fastly/index.test.ts
index 57135fdaee..aac6d16829 100644
--- runtime-tests/fastly/index.test.ts
+++ runtime-tests/fastly/index.test.ts
@@ -4,6 +4,10 @@ import { Hono } from '../../src/index'
import { basicAuth } from '../../src/middleware/basic-auth'
import { jwt } from '../../src/middleware/jwt'
+declare global {
+ var __fastlyComputeNodeDefaultCrypto: boolean | undefined
+}
+
beforeAll(() => {
vi.stubGlobal('fastly', true)
vi.stubGlobal('navigator', undefined)
@@ -99,7 +103,7 @@ describe('JWT Auth Middleware does not work', () => {
// To confirm polyfill-ed or not, check __fastlyComputeNodeDefaultCrypto field is true.
it.runIf(!globalThis.__fastlyComputeNodeDefaultCrypto)('Should throw error', () => {
expect(() => {
- app.use('/jwt/*', jwt({ secret: 'secret' }))
+ app.use('/jwt/*', jwt({ alg: 'HS256', secret: 'secret' }))
}).toThrow(/`crypto.subtle.importKey` is undefined/)
})
})
diff --git a/runtime-tests/fastly/tsconfig.json b/runtime-tests/fastly/tsconfig.json
new file mode 100644
index 0000000000..0aba81ca25
--- /dev/null
+++ runtime-tests/fastly/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["vitest/globals"]
+ },
+ "references": [
+ { "path": "../../tsconfig.build.json" }
+ ]
+}
diff --git a/runtime-tests/lambda-edge/tsconfig.json b/runtime-tests/lambda-edge/tsconfig.json
new file mode 100644
index 0000000000..0aba81ca25
--- /dev/null
+++ runtime-tests/lambda-edge/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["vitest/globals"]
+ },
+ "references": [
+ { "path": "../../tsconfig.build.json" }
+ ]
+}
diff --git runtime-tests/lambda/index.test.ts runtime-tests/lambda/index.test.ts
index 817cf4ab96..04e536c8d2 100644
--- runtime-tests/lambda/index.test.ts
+++ runtime-tests/lambda/index.test.ts
@@ -20,7 +20,7 @@ import type {
LambdaContext,
} from '../../src/adapter/aws-lambda/types'
import { getCookie, setCookie } from '../../src/helper/cookie'
-import { streamSSE } from '../../src/helper/streaming'
+import { streamSSE, streamText } from '../../src/helper/streaming'
import { Hono } from '../../src/hono'
import { basicAuth } from '../../src/middleware/basic-auth'
import './mock'
@@ -923,7 +923,7 @@ describe('streamHandle function', () => {
})
app.get('/stream/text', async (c) => {
- return c.streamText(async (stream) => {
+ return streamText(c, async (stream) => {
for (let i = 0; i < 3; i++) {
await stream.writeln(`${i}`)
await stream.sleep(1)
@@ -969,7 +969,8 @@ describe('streamHandle function', () => {
mockReadableStream.push('3\n')
mockReadableStream.push(null) // EOF
- await handler(event, mockReadableStream)
+ // @ts-expect-error should this be a ReadbleStream?
+ await handler(event, mockReadableStream, vi.fn())
const chunks = []
for await (const chunk of mockReadableStream) {
@@ -1013,7 +1014,8 @@ describe('streamHandle function', () => {
mockReadableStream.push('data: Message\ndata: It is 1\n\n')
mockReadableStream.push(null) // EOF
- await handler(event, mockReadableStream)
+ // @ts-expect-error should this be a ReadbleStream?
+ await handler(event, mockReadableStream, vi.fn())
const chunks = []
for await (const chunk of mockReadableStream) {
diff --git runtime-tests/lambda/mock.ts runtime-tests/lambda/mock.ts
index 9d1300ef0b..9aed7f9ad7 100644
--- runtime-tests/lambda/mock.ts
+++ runtime-tests/lambda/mock.ts
@@ -1,27 +1,23 @@
import { vi } from 'vitest'
-import type {
- APIGatewayProxyEvent,
- APIGatewayProxyEventV2,
- LambdaFunctionUrlEvent,
-} from '../../src/adapter/aws-lambda/handler'
+import type { LambdaEvent } from '../../src/adapter/aws-lambda/handler'
import type { LambdaContext } from '../../src/adapter/aws-lambda/types'
type StreamifyResponseHandler = (
handlerFunc: (
- event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | LambdaFunctionUrlEvent,
+ event: LambdaEvent,
responseStream: NodeJS.WritableStream,
context: LambdaContext
) => Promise<void>
-) => (event: APIGatewayProxyEvent, context: LambdaContext) => Promise<NodeJS.WritableStream>
+) => (event: LambdaEvent, context: LambdaContext) => Promise<NodeJS.WritableStream>
const mockStreamifyResponse: StreamifyResponseHandler = (handlerFunc) => {
return async (event, context) => {
const mockWritableStream: NodeJS.WritableStream = new (require('stream').Writable)({
- write(chunk, _encoding, callback) {
+ write(chunk: Buffer, _encoding: string, callback: () => void) {
console.log('Writing chunk:', chunk.toString())
callback()
},
- final(callback) {
+ final(callback: () => void) {
console.log('Finalizing stream.')
callback()
},
diff --git runtime-tests/lambda/stream-mock.ts runtime-tests/lambda/stream-mock.ts
index a2535e3bf1..a8106ca724 100644
--- runtime-tests/lambda/stream-mock.ts
+++ runtime-tests/lambda/stream-mock.ts
@@ -16,13 +16,14 @@ type StreamifyResponseHandler = (
const mockStreamifyResponse: StreamifyResponseHandler = (handlerFunc) => {
return async (event, context) => {
- const chunks = []
+ const chunks: unknown[] = []
const mockWritableStream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk)
callback()
},
})
+ // @ts-expect-error chunks property for testing
mockWritableStream.chunks = chunks
await handlerFunc(event, mockWritableStream, context)
mockWritableStream.end()
diff --git runtime-tests/lambda/stream.test.ts runtime-tests/lambda/stream.test.ts
index f4108b8ba1..c578af9379 100644
--- runtime-tests/lambda/stream.test.ts
+++ runtime-tests/lambda/stream.test.ts
@@ -67,7 +67,7 @@ describe('streamHandle function', () => {
requestContext: testApiGatewayRequestContextV2,
}
- const stream = await handler(event)
+ const stream = await handler(event, {} as LambdaContext, vi.fn())
const metadata = JSON.parse(stream.chunks[0].toString())
expect(metadata.cookies).toEqual(['cookie1=value1', 'cookie2=value2'])
diff --git a/runtime-tests/lambda/tsconfig.json b/runtime-tests/lambda/tsconfig.json
new file mode 100644
index 0000000000..0aba81ca25
--- /dev/null
+++ runtime-tests/lambda/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["vitest/globals"]
+ },
+ "references": [
+ { "path": "../../tsconfig.build.json" }
+ ]
+}
diff --git a/runtime-tests/node/tsconfig.json b/runtime-tests/node/tsconfig.json
new file mode 100644
index 0000000000..0aba81ca25
--- /dev/null
+++ runtime-tests/node/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["vitest/globals"]
+ },
+ "references": [
+ { "path": "../../tsconfig.build.json" }
+ ]
+}
diff --git runtime-tests/tsconfig.json runtime-tests/tsconfig.json
deleted file mode 100644
index 4ad7a5f74e..0000000000
--- runtime-tests/tsconfig.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "extends": "../tsconfig.json",
- "include": ["**/*.ts", "**/*.tsx"]
-}
diff --git a/runtime-tests/workerd/tsconfig.json b/runtime-tests/workerd/tsconfig.json
new file mode 100644
index 0000000000..0aba81ca25
--- /dev/null
+++ runtime-tests/workerd/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["vitest/globals"]
+ },
+ "references": [
+ { "path": "../../tsconfig.build.json" }
+ ]
+}
diff --git src/adapter/aws-lambda/handler.ts src/adapter/aws-lambda/handler.ts
index 858eaa45e3..ef7a89c510 100644
--- src/adapter/aws-lambda/handler.ts
+++ src/adapter/aws-lambda/handler.ts
@@ -453,12 +453,7 @@ export class EventV1Processor extends EventProcessor<APIGatewayProxyEvent> {
}
}
- protected getCookies(
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- event: APIGatewayProxyEvent,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- headers: Headers
- ): void {
+ protected getCookies(_event: APIGatewayProxyEvent, _headers: Headers): void {
// nop
}
diff --git src/adapter/lambda-edge/handler.test.ts src/adapter/lambda-edge/handler.test.ts
index f9378f5065..84ec3c7e7b 100644
--- src/adapter/lambda-edge/handler.test.ts
+++ src/adapter/lambda-edge/handler.test.ts
@@ -2,7 +2,7 @@ import { describe } from 'vitest'
import { setCookie } from '../../helper/cookie'
import { Hono } from '../../hono'
import { encodeBase64 } from '../../utils/encode'
-import type { CloudFrontEdgeEvent } from './handler'
+import type { Callback, CloudFrontEdgeEvent } from './handler'
import { createBody, handle, isContentTypeBinary } from './handler'
describe('isContentTypeBinary', () => {
@@ -89,6 +89,39 @@ describe('handle', () => {
expect(res.body).toBe('https://hono.dev/test-path')
})
+ it('Should expose async handler arity compatible with NODEJS_24_X', () => {
+ const app = new Hono()
+ const handler = handle(app)
+
+ expect(handler.length).toBeLessThanOrEqual(2)
+ })
+
+ it('Should preserve positional callback compatibility', async () => {
+ type Env = { Bindings: { callback: Callback } }
+ const app = new Hono<Env>()
+ const callback = vi.fn()
+
+ app.get('/test-path', (c) => {
+ c.env.callback?.(null, {
+ status: '200',
+ headers: {
+ 'x-test': [{ key: 'x-test', value: 'ok' }],
+ },
+ })
+ return c.text('ok')
+ })
+
+ const handler = handle(app)
+ await handler(cloudFrontEdgeEvent, undefined, callback)
+
+ expect(callback).toHaveBeenCalledWith(null, {
+ status: '200',
+ headers: {
+ 'x-test': [{ key: 'x-test', value: 'ok' }],
+ },
+ })
+ })
+
it('Should support multiple cookies', async () => {
const app = new Hono()
app.get('/test-path', (c) => {
diff --git src/adapter/lambda-edge/handler.ts src/adapter/lambda-edge/handler.ts
index 21c4cfe943..dc9eb7d113 100644
--- src/adapter/lambda-edge/handler.ts
+++ src/adapter/lambda-edge/handler.ts
@@ -121,7 +121,8 @@ export const handle = (
context?: CloudFrontContext,
callback?: Callback
) => Promise<CloudFrontResult>) => {
- return async (event, context?, callback?) => {
+ return async (event, ...args: [context?: CloudFrontContext, callback?: Callback]) => {
+ const [context, callback] = args
const res = await app.fetch(createRequest(event), {
event,
context,
diff --git src/jsx/dom/intrinsic-element/components.test.tsx src/jsx/dom/intrinsic-element/components.test.tsx
index 0df659d26f..2f91295fd5 100644
--- src/jsx/dom/intrinsic-element/components.test.tsx
+++ src/jsx/dom/intrinsic-element/components.test.tsx
@@ -313,6 +313,268 @@ describe('intrinsic element', () => {
await Promise.resolve()
expect(root.innerHTML).toBe('<div><div>Content</div><button>Show</button></div>')
})
+
+ describe('React 19 compatibility', () => {
+ type LinkSignatureInput = {
+ rel: string
+ href: string
+ hrefLang?: string
+ type?: string
+ title?: string
+ media?: string
+ as?: string
+ crossOrigin?: string
+ sizes?: string
+ dataPrecedence?: string
+ }
+
+ const sig = (v: LinkSignatureInput) =>
+ [
+ v.rel,
+ v.href,
+ v.hrefLang ?? '',
+ v.type ?? '',
+ v.title ?? '',
+ v.media ?? '',
+ v.as ?? '',
+ v.crossOrigin ?? '',
+ v.sizes ?? '',
+ v.dataPrecedence ?? '',
+ ].join('|')
+
+ const headLinkSignatures = () =>
+ Array.from(document.head.querySelectorAll('link')).map((el) =>
+ sig({
+ rel: el.getAttribute('rel') ?? '',
+ href: el.getAttribute('href') ?? '',
+ hrefLang: el.getAttribute('hreflang') ?? '',
+ type: el.getAttribute('type') ?? '',
+ title: el.getAttribute('title') ?? '',
+ media: el.getAttribute('media') ?? '',
+ as: el.getAttribute('as') ?? '',
+ crossOrigin: el.getAttribute('crossorigin') ?? '',
+ sizes: el.getAttribute('sizes') ?? '',
+ dataPrecedence: el.getAttribute('data-precedence') ?? '',
+ })
+ )
+
+ const assertHeadLinks = (
+ node: unknown,
+ expected: string[],
+ expectedRootInnerHTML?: string
+ ) => {
+ render(node as never, root)
+ expect(headLinkSignatures()).toEqual(expected)
+ if (expectedRootInnerHTML !== undefined) {
+ expect(root.innerHTML).toBe(expectedRootInnerHTML)
+ }
+ }
+
+ it('should keep canonical and alternates in source order', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
+ </div>,
+ [
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
+ sig({ rel: 'alternate', href: 'https://example.com/ja/about', hrefLang: 'ja' }),
+ ]
+ )
+ })
+
+ it('should keep alternate-canonical-alternate order', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
+ </div>,
+ [
+ sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ sig({ rel: 'alternate', href: 'https://example.com/ja/about', hrefLang: 'ja' }),
+ ]
+ )
+ })
+
+ it('should not de-duplicate canonical links', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='canonical' href='https://example.com/en/about' />
+ </div>,
+ [
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ ]
+ )
+ })
+
+ it('should not de-duplicate alternate links', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ </div>,
+ [
+ sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
+ sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
+ ]
+ )
+ })
+
+ it('should de-duplicate stylesheet with precedence', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ </div>,
+ [sig({ rel: 'stylesheet', href: '/style.css', dataPrecedence: 'default' })]
+ )
+ })
+
+ it('should not de-duplicate stylesheet against preload with same href', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='preload' href='/style.css' as='style' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ </div>,
+ [
+ sig({ rel: 'stylesheet', href: '/style.css', dataPrecedence: 'default' }),
+ sig({ rel: 'preload', href: '/style.css', as: 'style' }),
+ ]
+ )
+ })
+
+ it('should keep stylesheet links without precedence in root', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='stylesheet' href='/style.css' />
+ <link rel='stylesheet' href='/style.css' />
+ </div>,
+ [],
+ '<div><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/style.css"></div>'
+ )
+ })
+
+ it('should keep different stylesheets with same precedence', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='stylesheet' href='/a.css' precedence='default' />
+ <link rel='stylesheet' href='/b.css' precedence='default' />
+ </div>,
+ [
+ sig({ rel: 'stylesheet', href: '/a.css', dataPrecedence: 'default' }),
+ sig({ rel: 'stylesheet', href: '/b.css', dataPrecedence: 'default' }),
+ ]
+ )
+ })
+
+ it('should not de-duplicate preload links', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
+ <link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
+ </div>,
+ [
+ sig({ rel: 'preload', href: '/font.woff2', as: 'font', crossOrigin: '' }),
+ sig({ rel: 'preload', href: '/font.woff2', as: 'font', crossOrigin: '' }),
+ ]
+ )
+ })
+
+ it('should not de-duplicate modulepreload links', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='modulepreload' href='/module.js' />
+ <link rel='modulepreload' href='/module.js' />
+ </div>,
+ [
+ sig({ rel: 'modulepreload', href: '/module.js' }),
+ sig({ rel: 'modulepreload', href: '/module.js' }),
+ ]
+ )
+ })
+
+ it('should keep links from two components', () => {
+ const Head = () => (
+ <>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
+ </>
+ )
+ const Body = () => (
+ <>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
+ </>
+ )
+ assertHeadLinks(
+ <div>
+ <Head />
+ <Body />
+ </div>,
+ [
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
+ sig({ rel: 'alternate', href: 'https://example.com/ja/about', hrefLang: 'ja' }),
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
+ sig({ rel: 'alternate', href: 'https://example.com/ja/about', hrefLang: 'ja' }),
+ ]
+ )
+ })
+
+ it('should hoist from deep nested component and keep duplicates', () => {
+ const Nested = () => (
+ <div>
+ <div>
+ <link rel='canonical' href='https://example.com/en/about' />
+ </div>
+ </div>
+ )
+ assertHeadLinks(
+ <div>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <Nested />
+ </div>,
+ [
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ ]
+ )
+ })
+
+ it('should keep mixed links and de-duplicate only stylesheet', () => {
+ assertHeadLinks(
+ <div>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ <link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ <link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
+ </div>,
+ [
+ sig({ rel: 'stylesheet', href: '/style.css', dataPrecedence: 'default' }),
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
+ sig({ rel: 'preload', href: '/font.woff2', as: 'font', crossOrigin: '' }),
+ sig({ rel: 'canonical', href: 'https://example.com/en/about' }),
+ sig({ rel: 'alternate', href: 'https://example.com/en/about', hrefLang: 'en' }),
+ sig({ rel: 'preload', href: '/font.woff2', as: 'font', crossOrigin: '' }),
+ ]
+ )
+ })
+ })
})
describe('style element', () => {
@@ -719,6 +981,42 @@ describe('intrinsic element', () => {
expect(onError).toBeCalledTimes(1)
})
+ it('should not register load handlers when the tag has no de-duplication key', () => {
+ const addEventListener = vi.fn<HTMLElement['addEventListener']>()
+ const onLoad = vi.fn()
+ const onError = vi.fn()
+
+ const App = () => {
+ return (
+ <div>
+ <title
+ ref={(e: HTMLTitleElement) => {
+ if (!e) {
+ return
+ }
+ const originalAddEventListener = e.addEventListener.bind(e)
+ addEventListener.mockImplementation((...args) =>
+ originalAddEventListener(...args)
+ )
+ e.addEventListener = addEventListener
+ }}
+ onLoad={onLoad}
+ onError={onError}
+ >
+ Document Title
+ </title>
+ Content
+ </div>
+ )
+ }
+ render(<App />, root)
+ expect(document.head.innerHTML).toBe('<title>Document Title</title>')
+ expect(root.innerHTML).toBe('<div>Content</div>')
+ expect(addEventListener).not.toBeCalled()
+ expect(onLoad).not.toBeCalled()
+ expect(onError).not.toBeCalled()
+ })
+
it('should be blocked by blocking attribute', async () => {
const Component = () => {
return (
diff --git src/jsx/dom/intrinsic-element/components.ts src/jsx/dom/intrinsic-element/components.ts
index 9fc6fba13e..49526637fd 100644
--- src/jsx/dom/intrinsic-element/components.ts
+++ src/jsx/dom/intrinsic-element/components.ts
@@ -1,7 +1,13 @@
import type { Props } from '../../base'
import { useContext } from '../../context'
import { use, useCallback, useMemo, useState } from '../../hooks'
-import { dataPrecedenceAttr, deDupeKeyMap, domRenderers } from '../../intrinsic-element/common'
+import {
+ dataPrecedenceAttr,
+ deDupeKeyMap,
+ domRenderers,
+ isStylesheetLinkWithPrecedence,
+ shouldDeDupeByKey,
+} from '../../intrinsic-element/common'
import type { IntrinsicElements } from '../../intrinsic-elements'
import type { FC, JSXNode, PropsWithChildren, RefObject } from '../../types'
import { FormContext, registerAction } from '../hooks'
@@ -72,11 +78,17 @@ const documentMetadataTag = (
let created = false
const deDupeKeys = deDupeKeyMap[tag]
+ const deDupeByKey = shouldDeDupeByKey(tag, supportSort)
+ const isDeDupeCandidateLink = (e: HTMLElement) =>
+ e.getAttribute('rel') === 'stylesheet' && e.getAttribute(dataPrecedenceAttr) !== null
let existingElements: NodeListOf<HTMLElement> | undefined = undefined
- if (deDupeKeys.length > 0) {
+ if (deDupeByKey) {
const tags = head.querySelectorAll<HTMLElement>(tag)
LOOP: for (const e of tags) {
- for (const key of deDupeKeyMap[tag]) {
+ if (tag === 'link' && !isDeDupeCandidateLink(e)) {
+ continue
+ }
+ for (const key of deDupeKeys) {
if (e.getAttribute(key) === props[key]) {
element = e
break LOOP
@@ -96,10 +108,9 @@ const documentMetadataTag = (
if (props[key] !== undefined) {
e.setAttribute(key, props[key] as string)
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- if ((props as any).rel) {
- e.setAttribute('rel', props.rel)
- }
+ }
+ if (props.rel) {
+ e.setAttribute('rel', props.rel)
}
return e
})()
@@ -115,7 +126,29 @@ const documentMetadataTag = (
const insert = useCallback(
(e: HTMLElement) => {
- if (deDupeKeys.length > 0) {
+ if (deDupeByKey) {
+ if (tag === 'link' && precedence !== undefined) {
+ let found = false
+ for (const existingElement of head.querySelectorAll<HTMLElement>(tag)) {
+ const existingPrecedence = existingElement.getAttribute(dataPrecedenceAttr)
+ if (existingPrecedence === null) {
+ head.insertBefore(e, existingElement)
+ return
+ }
+ if (found && existingPrecedence !== precedence) {
+ head.insertBefore(e, existingElement)
+ return
+ }
+ if (existingPrecedence === precedence) {
+ found = true
+ }
+ }
+
+ // if sentinel is not found, append to the end
+ head.appendChild(e)
+ return
+ }
+
let found = false
for (const existingElement of head.querySelectorAll<HTMLElement>(tag)) {
if (found && existingElement.getAttribute(dataPrecedenceAttr) !== precedence) {
@@ -129,6 +162,10 @@ const documentMetadataTag = (
// if sentinel is not found, append to the end
head.appendChild(e)
+ } else if (tag === 'link') {
+ if (!head.contains(e)) {
+ head.appendChild(e)
+ }
} else if (existingElements) {
let found = false
for (const existingElement of existingElements!) {
@@ -147,7 +184,7 @@ const documentMetadataTag = (
existingElements = undefined
}
},
- [precedence]
+ [deDupeByKey, precedence, tag]
)
const ref = composeRef(props.ref, (e: HTMLElement) => {
@@ -164,6 +201,9 @@ const documentMetadataTag = (
if (!onError && !onLoad) {
return
}
+ if (!key) {
+ return
+ }
let promise = (blockingPromiseMap[e.getAttribute(key) as string] ||= new Promise<Event>(
(resolve, reject) => {
@@ -182,7 +222,7 @@ const documentMetadataTag = (
if (supportBlocking && blocking === 'render') {
const key = deDupeKeyMap[tag][0]
- if (props[key]) {
+ if (key && props[key]) {
const value = props[key]
const promise = (blockingPromiseMap[value] ||= new Promise<Event>((resolve, reject) => {
insert(element as HTMLElement)
@@ -268,7 +308,7 @@ export const link: FC<PropsWithChildren<IntrinsicElements['link']>> = (props) =>
ref: props.ref,
} as unknown as JSXNode
}
- return documentMetadataTag('link', props, 1, 'precedence' in props, true)
+ return documentMetadataTag('link', props, 1, isStylesheetLinkWithPrecedence(props), true)
}
export const meta: FC<PropsWithChildren> = (props) => {
diff --git src/jsx/intrinsic-element/common.ts src/jsx/intrinsic-element/common.ts
index 8c066ddbee..d1a349de8a 100644
--- src/jsx/intrinsic-element/common.ts
+++ src/jsx/intrinsic-element/common.ts
@@ -1,3 +1,5 @@
+import type { Props } from '../base'
+
export const deDupeKeyMap: Record<string, string[]> = {
title: [],
script: ['src'],
@@ -9,3 +11,13 @@ export const deDupeKeyMap: Record<string, string[]> = {
export const domRenderers: Record<string, Function> = {}
export const dataPrecedenceAttr = 'data-precedence'
+
+export const isStylesheetLinkWithPrecedence = (props: Props): boolean =>
+ props.rel === 'stylesheet' && 'precedence' in props
+
+export const shouldDeDupeByKey = (tagName: string, supportSort: boolean): boolean => {
+ if (tagName === 'link') {
+ return supportSort
+ }
+ return deDupeKeyMap[tagName].length > 0
+}
diff --git src/jsx/intrinsic-element/components.test.tsx src/jsx/intrinsic-element/components.test.tsx
index a569330051..7ab40fa1df 100644
--- src/jsx/intrinsic-element/components.test.tsx
+++ src/jsx/intrinsic-element/components.test.tsx
@@ -115,6 +115,193 @@ describe('intrinsic element', () => {
'<html><head></head><body><link rel="stylesheet" href="style1.css"/><h1>World</h1></body></html>'
)
})
+
+ describe('React 19 compatibility', () => {
+ const headContent = (html: string) => html.match(/<head>(.*)<\/head>/)?.[1] ?? ''
+ const bodyContent = (html: string) => html.match(/<body>(.*)<\/body>/)?.[1] ?? ''
+ const renderDocument = (children: unknown) =>
+ (
+ <html>
+ <head></head>
+ <body>{children}</body>
+ </html>
+ ).toString()
+ const assertHeadAndBody = (children: unknown, expectedHead: string, expectedBody = '') => {
+ const output = renderDocument(children)
+ expect(headContent(output)).toBe(expectedHead)
+ expect(bodyContent(output)).toBe(expectedBody)
+ }
+
+ const canonical = () => <link rel='canonical' href='https://example.com/en/about' />
+ const alternateEn = () => (
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ )
+ const alternateJa = () => (
+ <link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
+ )
+
+ it('should keep canonical and alternates in source order', () => {
+ assertHeadAndBody(
+ <>
+ {canonical()}
+ {alternateEn()}
+ {alternateJa()}
+ </>,
+ '<link rel="canonical" href="https://example.com/en/about"/><link rel="alternate" hrefLang="en" href="https://example.com/en/about"/><link rel="alternate" hrefLang="ja" href="https://example.com/ja/about"/>'
+ )
+ })
+
+ it('should keep alternate-canonical-alternate order', () => {
+ assertHeadAndBody(
+ <>
+ {alternateEn()}
+ {canonical()}
+ {alternateJa()}
+ </>,
+ '<link rel="alternate" hrefLang="en" href="https://example.com/en/about"/><link rel="canonical" href="https://example.com/en/about"/><link rel="alternate" hrefLang="ja" href="https://example.com/ja/about"/>'
+ )
+ })
+
+ it('should not de-dupe canonical links', () => {
+ assertHeadAndBody(
+ <>
+ {canonical()}
+ {canonical()}
+ </>,
+ '<link rel="canonical" href="https://example.com/en/about"/><link rel="canonical" href="https://example.com/en/about"/>'
+ )
+ })
+
+ it('should not de-dupe alternate links', () => {
+ assertHeadAndBody(
+ <>
+ {alternateEn()}
+ {alternateEn()}
+ </>,
+ '<link rel="alternate" hrefLang="en" href="https://example.com/en/about"/><link rel="alternate" hrefLang="en" href="https://example.com/en/about"/>'
+ )
+ })
+
+ it('should de-dupe stylesheet with precedence', () => {
+ assertHeadAndBody(
+ <>
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ </>,
+ '<link rel="stylesheet" href="/style.css" data-precedence="default"/>'
+ )
+ })
+
+ it('should not de-dupe stylesheet against preload with same href', () => {
+ assertHeadAndBody(
+ <>
+ <link rel='preload' href='/style.css' as='style' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ </>,
+ '<link rel="stylesheet" href="/style.css" data-precedence="default"/><link rel="preload" href="/style.css" as="style"/>'
+ )
+ })
+
+ it('should not hoist stylesheet without precedence', () => {
+ assertHeadAndBody(
+ <>
+ <link rel='stylesheet' href='/style.css' />
+ <link rel='stylesheet' href='/style.css' />
+ </>,
+ '',
+ '<link rel="stylesheet" href="/style.css"/><link rel="stylesheet" href="/style.css"/>'
+ )
+ })
+
+ it('should keep different stylesheets with same precedence', () => {
+ assertHeadAndBody(
+ <>
+ <link rel='stylesheet' href='/a.css' precedence='default' />
+ <link rel='stylesheet' href='/b.css' precedence='default' />
+ </>,
+ '<link rel="stylesheet" href="/a.css" data-precedence="default"/><link rel="stylesheet" href="/b.css" data-precedence="default"/>'
+ )
+ })
+
+ it('should not de-dupe preload links', () => {
+ assertHeadAndBody(
+ <>
+ <link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
+ <link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
+ </>,
+ '<link rel="preload" href="/font.woff2" as="font" crossorigin=""/><link rel="preload" href="/font.woff2" as="font" crossorigin=""/>'
+ )
+ })
+
+ it('should not de-dupe modulepreload links', () => {
+ assertHeadAndBody(
+ <>
+ <link rel='modulepreload' href='/module.js' />
+ <link rel='modulepreload' href='/module.js' />
+ </>,
+ '<link rel="modulepreload" href="/module.js"/><link rel="modulepreload" href="/module.js"/>'
+ )
+ })
+
+ it('should keep links from two components', () => {
+ const Head = () => (
+ <>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
+ </>
+ )
+ const Body = () => (
+ <>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='ja' href='https://example.com/ja/about' />
+ </>
+ )
+ assertHeadAndBody(
+ <>
+ <Head />
+ <Body />
+ </>,
+ '<link rel="canonical" href="https://example.com/en/about"/><link rel="alternate" hrefLang="en" href="https://example.com/en/about"/><link rel="alternate" hrefLang="ja" href="https://example.com/ja/about"/><link rel="canonical" href="https://example.com/en/about"/><link rel="alternate" hrefLang="en" href="https://example.com/en/about"/><link rel="alternate" hrefLang="ja" href="https://example.com/ja/about"/>'
+ )
+ })
+
+ it('should hoist from deep nested component and keep duplicates', () => {
+ const Nested = () => (
+ <div>
+ <div>
+ <link rel='canonical' href='https://example.com/en/about' />
+ </div>
+ </div>
+ )
+ assertHeadAndBody(
+ <>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <Nested />
+ </>,
+ '<link rel="canonical" href="https://example.com/en/about"/><link rel="canonical" href="https://example.com/en/about"/>',
+ '<div><div></div></div>'
+ )
+ })
+
+ it('should keep mixed links and de-dupe only stylesheet', () => {
+ assertHeadAndBody(
+ <>
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ <link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
+ <link rel='canonical' href='https://example.com/en/about' />
+ <link rel='alternate' hrefLang='en' href='https://example.com/en/about' />
+ <link rel='stylesheet' href='/style.css' precedence='default' />
+ <link rel='preload' href='/font.woff2' as='font' crossOrigin='' />
+ </>,
+ '<link rel="stylesheet" href="/style.css" data-precedence="default"/><link rel="canonical" href="https://example.com/en/about"/><link rel="alternate" hrefLang="en" href="https://example.com/en/about"/><link rel="preload" href="/font.woff2" as="font" crossorigin=""/><link rel="canonical" href="https://example.com/en/about"/><link rel="alternate" hrefLang="en" href="https://example.com/en/about"/><link rel="preload" href="/font.woff2" as="font" crossorigin=""/>'
+ )
+ })
+ })
})
describe('meta element', () => {
diff --git src/jsx/intrinsic-element/components.ts src/jsx/intrinsic-element/components.ts
index e711a9322e..7629e0a9c7 100644
--- src/jsx/intrinsic-element/components.ts
+++ src/jsx/intrinsic-element/components.ts
@@ -7,7 +7,12 @@ import { PERMALINK } from '../constants'
import { useContext } from '../context'
import type { IntrinsicElements } from '../intrinsic-elements'
import type { FC, PropsWithChildren } from '../types'
-import { dataPrecedenceAttr, deDupeKeyMap } from './common'
+import {
+ dataPrecedenceAttr,
+ deDupeKeyMap,
+ isStylesheetLinkWithPrecedence,
+ shouldDeDupeByKey,
+} from './common'
const metaTagMap: WeakMap<
object,
@@ -30,8 +35,15 @@ const insertIntoHead: (
let duped = false
const deDupeKeys = deDupeKeyMap[tagName]
- if (deDupeKeys.length > 0) {
+ const deDupeByKey = shouldDeDupeByKey(tagName, precedence !== undefined)
+ if (deDupeByKey) {
LOOP: for (const [, tagProps] of tags) {
+ if (
+ tagName === 'link' &&
+ !(tagProps.rel === 'stylesheet' && tagProps[dataPrecedenceAttr] !== undefined)
+ ) {
+ continue
+ }
for (const key of deDupeKeys) {
if ((tagProps?.[key] ?? null) === props?.[key]) {
duped = true
@@ -43,7 +55,7 @@ const insertIntoHead: (
if (duped) {
buffer[0] = buffer[0].replaceAll(tag, '')
- } else if (deDupeKeys.length > 0) {
+ } else if (deDupeByKey || tagName === 'link') {
tags.push([tag, props, precedence])
} else {
tags.unshift([tag, props, precedence])
@@ -51,21 +63,24 @@ const insertIntoHead: (
if (buffer[0].indexOf('</head>') !== -1) {
let insertTags
- if (precedence === undefined) {
- insertTags = tags.map(([tag]) => tag)
- } else {
+ if (tagName === 'link' || precedence !== undefined) {
const precedences: string[] = []
insertTags = tags
- .map(([tag, , precedence]) => {
- let order = precedences.indexOf(precedence as string)
+ .map(([tag, , tagPrecedence], index) => {
+ if (tagPrecedence === undefined) {
+ return [tag, Number.MAX_SAFE_INTEGER, index] as [string, number, number]
+ }
+ let order = precedences.indexOf(tagPrecedence as string)
if (order === -1) {
- precedences.push(precedence as string)
+ precedences.push(tagPrecedence as string)
order = precedences.length - 1
}
- return [tag, order] as [string, number]
+ return [tag, order, index] as [string, number, number]
})
- .sort((a, b) => a[1] - b[1])
+ .sort((a, b) => a[1] - b[1] || a[2] - b[2])
.map(([tag]) => tag)
+ } else {
+ insertTags = tags.map(([tag]) => tag)
}
insertTags.forEach((tag) => {
@@ -151,7 +166,7 @@ export const link: FC<PropsWithChildren<IntrinsicElements['link']>> = ({ childre
) {
return returnWithoutSpecialBehavior('link', children, props)
}
- return documentMetadataTag('link', children, props, 'precedence' in props)
+ return documentMetadataTag('link', children, props, isStylesheetLinkWithPrecedence(props))
}
export const meta: FC<PropsWithChildren> = ({ children, ...props }) => {
const nameSpaceContext = getNameSpaceContext()
diff --git src/jsx/types.ts src/jsx/types.ts
index 14a4021634..cb2fb52a91 100644
--- src/jsx/types.ts
+++ src/jsx/types.ts
@@ -22,8 +22,7 @@ type ReactElement<P = any, T = string | Function> = JSXNode & {
key: string | null
}
type ReactNode = ReactElement | string | number | boolean | null | undefined
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-type ComponentClass<P = {}, S = {}> = unknown
+type ComponentClass<_P = {}, _S = {}> = unknown
export type { ReactElement, ReactNode, ComponentClass }
diff --git src/middleware/jsx-renderer/index.test.tsx src/middleware/jsx-renderer/index.test.tsx
index 042a1b6331..ad65c7a7e0 100644
--- src/middleware/jsx-renderer/index.test.tsx
+++ src/middleware/jsx-renderer/index.test.tsx
@@ -407,6 +407,62 @@ d.replaceWith(c.content)
expect(await res.text()).toBe('<!DOCTYPE html><div>Hi</div>')
})
+ it('Should accept function-based options', async () => {
+ type Env = { Bindings: { HONO_STREAMING?: boolean } }
+ const app = new Hono<Env>()
+
+ const Component = async () => {
+ return <div>Component</div>
+ }
+
+ app.use(
+ '*',
+ jsxRenderer<Env>(
+ ({ children }) => {
+ return (
+ <html>
+ <body>{children}</body>
+ </html>
+ )
+ },
+ (c) => {
+ expectTypeOf(c.env?.HONO_STREAMING).toEqualTypeOf<boolean | undefined>()
+ return { docType: true, stream: c.env?.HONO_STREAMING ?? true }
+ }
+ )
+ )
+
+ app.get('/', async (c) => {
+ return c.render(
+ <div>
+ <Suspense fallback={'loading...'}>
+ <Component />
+ </Suspense>
+ </div>,
+ { title: 'Suspense test' }
+ )
+ })
+
+ const resStream = await app.request('/')
+ expect(resStream.status).toBe(200)
+ expect(resStream.headers.get('Transfer-Encoding')).toBe('chunked')
+ const textStream = await resStream.text()
+ expect(textStream).toContain('<template')
+ expect(textStream).toContain('<script')
+ expect(textStream).toContain('loading...')
+
+ const resNotStream = await app.request('/', undefined, { HONO_STREAMING: false })
+ expect(resNotStream.status).toBe(200)
+ expect(resNotStream.headers.get('Transfer-Encoding')).toBeNull()
+ const textNotStream = await resNotStream.text()
+ expect(textNotStream).not.toContain('<template')
+ expect(textNotStream).not.toContain('<script')
+ expect(textNotStream).not.toContain('loading...')
+ expect(textNotStream).toBe(
+ '<!DOCTYPE html><html><body><div><div>Component</div></div></body></html>'
+ )
+ })
+
describe('keep context status', async () => {
it('Should keep context status', async () => {
const app = new Hono()
diff --git src/middleware/jsx-renderer/index.ts src/middleware/jsx-renderer/index.ts
index ba78fce224..851d014d7c 100644
--- src/middleware/jsx-renderer/index.ts
+++ src/middleware/jsx-renderer/index.ts
@@ -31,8 +31,14 @@ type ComponentWithChildren = (
) => HtmlEscapedString | Promise<HtmlEscapedString>
const createRenderer =
- (c: Context, Layout: FC, component?: Component, options?: RendererOptions) =>
+ (
+ c: Context,
+ Layout: FC,
+ component?: Component,
+ options?: RendererOptions | ((c: Context) => RendererOptions)
+ ) =>
(children: JSXNode, props: PropsForRenderer) => {
+ options = typeof options === 'function' ? options(c) : options
const docType =
typeof options?.docType === 'string'
? options.docType
@@ -107,9 +113,9 @@ const createRenderer =
* })
* ```
*/
-export const jsxRenderer = (
+export const jsxRenderer = <E extends Env = Env>(
component?: ComponentWithChildren,
- options?: RendererOptions
+ options?: RendererOptions | ((c: Context<E>) => RendererOptions)
): MiddlewareHandler =>
function jsxRenderer(c, next) {
const Layout = (c.getLayout() ?? Fragment) as FC
diff --git src/utils/accept.test.ts src/utils/accept.test.ts
index ee7f2800dc..145a6705fe 100644
--- src/utils/accept.test.ts
+++ src/utils/accept.test.ts
@@ -17,6 +17,11 @@ describe('parseAccept Comprehensive Tests', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(parseAccept(null as any)).toEqual([])
})
+
+ test('handles whitespace-only header', () => {
+ expect(parseAccept(' ')).toEqual([])
+ expect(parseAccept(' \t\n ')).toEqual([])
+ })
})
describe('Quality Values', () => {
@@ -46,11 +51,9 @@ describe('parseAccept Comprehensive Tests', () => {
const result = parseAccept(header)
expect(result[0].params).toEqual({
a: '1',
- b: '"2"',
-
+ b: '2',
c: "'3'",
- d: '"semi;colon"',
- e: '"nested"quoted""',
+ d: 'semi;colon',
})
})
@@ -105,6 +108,100 @@ describe('parseAccept Comprehensive Tests', () => {
const result = parseAccept(header)
expect(result.map((x) => x.type)).toEqual(['b', 'a'])
})
+
+ test('handles comma inside quoted parameter value', () => {
+ const header = 'text/plain;meta="a,b";q=0.8,application/json;q=0.7'
+ const result = parseAccept(header)
+ expect(result).toEqual([
+ {
+ type: 'text/plain',
+ params: {
+ meta: 'a,b',
+ q: '0.8',
+ },
+ q: 0.8,
+ },
+ {
+ type: 'application/json',
+ params: {
+ q: '0.7',
+ },
+ q: 0.7,
+ },
+ ])
+ })
+
+ test('handles escaped quote and semicolon inside quoted parameter', () => {
+ const header = 'text/plain;meta="a\\\";b";q=0.5'
+ const result = parseAccept(header)
+ expect(result).toEqual([
+ {
+ type: 'text/plain',
+ params: {
+ meta: 'a";b',
+ q: '0.5',
+ },
+ q: 0.5,
+ },
+ ])
+ })
+
+ test('handles escaped character inside quoted parameter', () => {
+ const header = 'text/plain;meta="a\\\\z;c";q=0.3'
+ const result = parseAccept(header)
+ expect(result).toEqual([
+ {
+ type: 'text/plain',
+ params: {
+ meta: 'a\\z;c',
+ q: '0.3',
+ },
+ q: 0.3,
+ },
+ ])
+ })
+
+ test('skips invalid param without swallowing next media type', () => {
+ const header = 'a;foo, b;q=0.5'
+ const result = parseAccept(header)
+ expect(result).toEqual([
+ { type: 'a', params: {}, q: 1 },
+ { type: 'b', params: { q: '0.5' }, q: 0.5 },
+ ])
+ })
+
+ test('skips malformed quoted param tail without creating bogus media type', () => {
+ const header = 'a;foo="x"bar,b'
+ const result = parseAccept(header)
+ expect(result).toEqual([
+ { type: 'a', params: {}, q: 1 },
+ { type: 'b', params: {}, q: 1 },
+ ])
+ })
+
+ test('parses params after quoted value with trailing whitespace', () => {
+ const header = 'a;foo="x" ;q=0.5,b;q=0.4'
+ const result = parseAccept(header)
+ expect(result).toEqual([
+ { type: 'a', params: { foo: 'x', q: '0.5' }, q: 0.5 },
+ { type: 'b', params: { q: '0.4' }, q: 0.4 },
+ ])
+ })
+
+ test('handles quoted param followed immediately by comma', () => {
+ const header = 'a;foo="x",b'
+ const result = parseAccept(header)
+ expect(result).toEqual([
+ { type: 'a', params: { foo: 'x' }, q: 1 },
+ { type: 'b', params: {}, q: 1 },
+ ])
+ })
+
+ test('skips empty media type that starts with semicolon', () => {
+ const header = ';q=0.5,b;q=0.4'
+ const result = parseAccept(header)
+ expect(result).toEqual([{ type: 'b', params: { q: '0.4' }, q: 0.4 }])
+ })
})
describe('Security Cases', () => {
@@ -121,6 +218,12 @@ describe('parseAccept Comprehensive Tests', () => {
})
})
+ test('handles many semicolons with an unbalanced quote', () => {
+ const header = `text/plain;${'a;'.repeat(8000)}"`
+ const result = parseAccept(header)
+ expect(result[0].type).toBe('text/plain')
+ })
+
test('handles extremely large input', () => {
const header = 'a;q=0.9,'.repeat(100000)
expect(() => parseAccept(header)).not.toThrow()
diff --git src/utils/accept.ts src/utils/accept.ts
index 73f3a8dfee..87f98e35db 100644
--- src/utils/accept.ts
+++ src/utils/accept.ts
@@ -4,49 +4,237 @@ export interface Accept {
q: number
}
-/**
- * Parse an Accept header into an array of objects with type, parameters, and quality score.
- * @param acceptHeader The Accept header string
- * @returns An array of parsed Accept values
- */
-export const parseAccept = (acceptHeader: string): Accept[] => {
- if (!acceptHeader) {
- return []
+const isWhitespace = (char: number): boolean =>
+ char === 32 || char === 9 || char === 10 || char === 13
+
+const consumeWhitespace = (acceptHeader: string, startIndex: number): number => {
+ while (startIndex < acceptHeader.length) {
+ if (!isWhitespace(acceptHeader.charCodeAt(startIndex))) {
+ break
+ }
+ startIndex++
+ }
+ return startIndex
+}
+
+const ignoreTrailingWhitespace = (acceptHeader: string, startIndex: number): number => {
+ while (startIndex > 0) {
+ if (!isWhitespace(acceptHeader.charCodeAt(startIndex - 1))) {
+ break
+ }
+ startIndex--
}
+ return startIndex
+}
- const acceptValues = acceptHeader.split(',').map((value, index) => ({ value, index }))
+const skipInvalidParam = (acceptHeader: string, startIndex: number): [number, boolean] => {
+ while (startIndex < acceptHeader.length) {
+ const char = acceptHeader.charCodeAt(startIndex)
+ if (char === 59) {
+ // ';' => next param
+ return [startIndex + 1, true]
+ }
+ if (char === 44) {
+ // ',' => next accept value
+ return [startIndex + 1, false]
+ }
+ startIndex++
+ }
+ return [startIndex, false]
+}
- return acceptValues
- .map(parseAcceptValue)
- .filter((item): item is Accept & { index: number } => Boolean(item))
- .sort(sortByQualityAndIndex)
- .map(({ type, params, q }) => ({ type, params, q }))
+const skipInvalidAcceptValue = (acceptHeader: string, startIndex: number): number => {
+ let i = startIndex
+ let inQuotes = false
+ while (i < acceptHeader.length) {
+ const char = acceptHeader.charCodeAt(i)
+ if (inQuotes && char === 92) {
+ // '\' => escape
+ i++
+ } else if (char === 34) {
+ // '"' => toggle quotes
+ inQuotes = !inQuotes
+ } else if (!inQuotes && char === 44) {
+ // ',' => next accept value
+ return i + 1
+ }
+ i++
+ }
+ return i
}
-const parseAcceptValueRegex = /;(?=(?:(?:[^"]*"){2})*[^"]*$)/
-const parseAcceptValue = ({ value, index }: { value: string; index: number }) => {
- const parts = value
- .trim()
- .split(parseAcceptValueRegex)
- .map((s) => s.trim())
- const type = parts[0]
- if (!type) {
- return null
+
+const getNextParam = (
+ acceptHeader: string,
+ startIndex: number
+): [number, string | undefined, string | undefined, boolean] => {
+ startIndex = consumeWhitespace(acceptHeader, startIndex)
+ let i = startIndex
+ let key: string | undefined
+ let value: string | undefined
+ let hasNext = false
+ while (i < acceptHeader.length) {
+ const char = acceptHeader.charCodeAt(i)
+ if (char === 61) {
+ // '=' => end of key
+ key = acceptHeader.slice(startIndex, ignoreTrailingWhitespace(acceptHeader, i))
+ i++
+ break
+ }
+ if (char === 59) {
+ // ';' => invalid empty param, continue parsing params
+ return [i + 1, undefined, undefined, true]
+ }
+ if (char === 44) {
+ // ',' => invalid empty param, move to next accept value
+ return [i + 1, undefined, undefined, false]
+ }
+ i++
+ }
+ if (key === undefined) {
+ return [i, undefined, undefined, false]
}
- const params = parseParams(parts.slice(1))
- const q = parseQuality(params.q)
+ i = consumeWhitespace(acceptHeader, i)
+ if (acceptHeader.charCodeAt(i) === 61) {
+ // '=' is invalid as a value, so return undefined
+ const skipResult = skipInvalidParam(acceptHeader, i + 1)
+ return [skipResult[0], key, undefined, skipResult[1]]
+ }
+
+ let inQuotes = false
+ const paramStartIndex = i
+ while (i < acceptHeader.length) {
+ const char = acceptHeader.charCodeAt(i)
- return { type, params, q, index }
+ if (inQuotes && char === 92) {
+ // '\' => escape
+ i++
+ } else if (char === 34) {
+ // '"' => start of quotes
+ if (inQuotes) {
+ let nextIndex = consumeWhitespace(acceptHeader, i + 1)
+ const nextChar = acceptHeader.charCodeAt(nextIndex)
+ if (nextIndex < acceptHeader.length && !(nextChar === 59 || nextChar === 44)) {
+ // not ';' or ',' => invalid trailing chars
+ const skipResult = skipInvalidParam(acceptHeader, nextIndex)
+ return [skipResult[0], key, undefined, skipResult[1]]
+ }
+ value = acceptHeader.slice(paramStartIndex + 1, i)
+ if (value.includes('\\')) {
+ value = value.replace(/\\(.)/g, '$1')
+ }
+ if (nextChar === 44) {
+ // ',' => end of accept value
+ return [nextIndex + 1, key, value, false]
+ }
+ if (nextChar === 59) {
+ // ';' => has next param
+ hasNext = true
+ nextIndex++
+ }
+ i = nextIndex
+ break
+ }
+ inQuotes = true
+ } else if (!inQuotes && (char === 59 || char === 44)) {
+ // ';' or ',' => end of value
+ value = acceptHeader.slice(paramStartIndex, ignoreTrailingWhitespace(acceptHeader, i))
+ if (char === 59) {
+ // ';' => has next param
+ hasNext = true
+ }
+ i++
+ break
+ }
+ i++
+ }
+ return [
+ i,
+ key,
+ value ?? acceptHeader.slice(paramStartIndex, ignoreTrailingWhitespace(acceptHeader, i)),
+ hasNext,
+ ]
}
-const parseParams = (paramParts: string[]): Record<string, string> => {
- return paramParts.reduce<Record<string, string>>((acc, param) => {
- const [key, val] = param.split('=').map((s) => s.trim())
- if (key && val) {
- acc[key] = val
+const getNextAcceptValue = (
+ acceptHeader: string,
+ startIndex: number
+): [number, Accept | undefined] => {
+ const accept: Accept = {
+ type: '',
+ params: {},
+ q: 1,
+ }
+ startIndex = consumeWhitespace(acceptHeader, startIndex)
+ let i = startIndex
+ while (i < acceptHeader.length) {
+ const char = acceptHeader.charCodeAt(i)
+ if (char === 59 || char === 44) {
+ // ';' or ',' => end of type
+ accept.type = acceptHeader.slice(startIndex, ignoreTrailingWhitespace(acceptHeader, i))
+ i++
+ if (char === 44) {
+ // ',' => end of value
+ return [i, accept.type ? accept : undefined]
+ }
+ if (!accept.type) {
+ return [skipInvalidAcceptValue(acceptHeader, i), undefined]
+ }
+ break // parse params
+ }
+ i++
+ }
+ if (!accept.type) {
+ accept.type = acceptHeader.slice(
+ startIndex,
+ ignoreTrailingWhitespace(acceptHeader, acceptHeader.length)
+ )
+ return [acceptHeader.length, accept.type ? accept : undefined]
+ }
+
+ let param: string | undefined
+ let value: string | undefined
+ let hasNext: boolean
+ while (i < acceptHeader.length) {
+ ;[i, param, value, hasNext] = getNextParam(acceptHeader, i)
+ if (param && value) {
+ accept.params[param] = value
}
- return acc
- }, {})
+ if (!hasNext) {
+ break
+ }
+ }
+
+ return [i, accept] as [number, Accept]
+}
+
+export const parseAccept = (acceptHeader: string): Accept[] => {
+ if (!acceptHeader) {
+ return []
+ }
+
+ const values: Accept[] = []
+ let i = 0
+ let accept: Accept | undefined
+ let requiresSort = false // in many cases, accept values are already sorted by quality (e.g. "text/html, application/json;q=0.9, */*;q=0.8")
+ let lastAccept: Accept | undefined
+ while (i < acceptHeader.length) {
+ ;[i, accept] = getNextAcceptValue(acceptHeader, i)
+ if (accept) {
+ accept.q = parseQuality(accept.params.q)
+ values.push(accept)
+ if (lastAccept && lastAccept.q < accept.q) {
+ // find higher quality accept value, so we need to sort
+ requiresSort = true
+ }
+ lastAccept = accept
+ }
+ }
+ if (requiresSort) {
+ values.sort((a, b) => b.q - a.q)
+ }
+
+ return values
}
const parseQuality = (qVal?: string): number => {
@@ -76,11 +264,3 @@ const parseQuality = (qVal?: string): number => {
return num
}
-
-const sortByQualityAndIndex = (a: Accept & { index: number }, b: Accept & { index: number }) => {
- const qDiff = b.q - a.q
- if (qDiff !== 0) {
- return qDiff
- }
- return a.index - b.index
-}
diff --git src/utils/body.test.ts src/utils/body.test.ts
index 0b1f8a0bf9..5fa3879c82 100644
--- src/utils/body.test.ts
+++ src/utils/body.test.ts
@@ -160,6 +160,63 @@ describe('Parse Body Util', () => {
})
})
+ it('should skip keys starting with __proto__. to prevent prototype pollution', async () => {
+ const data = new FormData()
+ data.append('__proto__.polluted', 'malicious')
+
+ const req = createRequest(FORM_URL, 'POST', data)
+
+ expect(await parseBody(req, { dot: true })).toEqual({})
+ })
+
+ it('should skip keys containing nested __proto__. to prevent prototype pollution', async () => {
+ const data = new FormData()
+ data.append('a.__proto__.polluted', 'malicious')
+
+ const req = createRequest(FORM_URL, 'POST', data)
+
+ expect(await parseBody(req, { dot: true })).toEqual({})
+ })
+
+ it('should not pollute Object.prototype via __proto__ keys', async () => {
+ const data = new FormData()
+ data.append('__proto__.injected', 'yes')
+ data.append('a.__proto__.injected', 'yes')
+
+ const req = createRequest(FORM_URL, 'POST', data)
+
+ await parseBody(req, { dot: true })
+
+ expect(({} as Record<string, unknown>).injected).toBeUndefined()
+ })
+
+ it('should parse key ending with __proto__ as a normal value', async () => {
+ const data = new FormData()
+ data.append('a.__proto__', 'value')
+
+ const req = createRequest(FORM_URL, 'POST', data)
+
+ const result = await parseBody(req, { dot: true })
+ expect(result).toHaveProperty('a')
+ expect(
+ Object.getOwnPropertyDescriptor(
+ (result as Record<string, Record<string, string>>).a,
+ '__proto__'
+ )?.value
+ ).toBe('value')
+ })
+
+ it('should parse key containing __proto__ as a substring normally', async () => {
+ const data = new FormData()
+ data.append('data__proto__key.value', 'test')
+
+ const req = createRequest(FORM_URL, 'POST', data)
+
+ expect(await parseBody(req, { dot: true })).toEqual({
+ data__proto__key: { value: 'test' },
+ })
+ })
+
it('should parse nested values if `dot` option is true', async () => {
const data = new FormData()
data.append('obj.key1', 'value1')
diff --git src/utils/body.ts src/utils/body.ts
index 6338805a78..b7fdf1b36a 100644
--- src/utils/body.ts
+++ src/utils/body.ts
@@ -208,6 +208,10 @@ const handleParsingNestedValues = (
key: string,
value: BodyDataValue<Partial<ParseBodyOptions>>
): void => {
+ if (/(?:^|\.)__proto__\./.test(key)) {
+ return
+ }
+
let nestedForm = form
const keys = key.split('.')
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000000..98b6c1b369
--- /dev/null
+++ tsconfig.base.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "target": "ES2022",
+ "declaration": true,
+ "moduleResolution": "Bundler",
+ "outDir": "${configDir}/dist",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false
+ }
+}
diff --git tsconfig.build.json tsconfig.build.json
index 844f9729c6..7843b827c4 100644
--- tsconfig.build.json
+++ tsconfig.build.json
@@ -1,5 +1,5 @@
{
- "extends": "./tsconfig.json",
+ "extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "ES2020",
"rootDir": "./src/",
diff --git tsconfig.json tsconfig.json
index 561cc2e469..67179cb479 100644
--- tsconfig.json
+++ tsconfig.json
@@ -1,31 +1,14 @@
{
- "compilerOptions": {
- "target": "ES2022",
- "declaration": true,
- "moduleResolution": "Bundler",
- "outDir": "./dist",
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "strict": true,
- "skipLibCheck": true,
- "noUnusedLocals": false,
- "noUnusedParameters": false,
- "types": [
- "node",
- "vitest/globals"
- ],
- "jsx": "react",
- "jsxFactory": "jsx",
- "jsxFragmentFactory": "Fragment"
- },
- "include": [
- "src/**/*.ts",
- "src/**/*.d.ts",
- "src/**/*.mts",
- "src/**/*.test.ts",
- "src/**/*.test.tsx"
- ],
- "exclude": [
- "node_modules/*"
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.build.json" },
+ { "path": "./tsconfig.spec.json" },
+ { "path": "./perf-measures/type-check/scripts/tsconfig.json" },
+ { "path": "./runtime-tests/bun/tsconfig.json" },
+ { "path": "./runtime-tests/fastly/tsconfig.json" },
+ { "path": "./runtime-tests/lambda/tsconfig.json" },
+ { "path": "./runtime-tests/lambda-edge/tsconfig.json" },
+ { "path": "./runtime-tests/node/tsconfig.json" },
+ { "path": "./runtime-tests/workerd/tsconfig.json" }
]
}
diff --git a/tsconfig.spec.json b/tsconfig.spec.json
new file mode 100644
index 0000000000..3c04d8ad09
--- /dev/null
+++ tsconfig.spec.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx",
+ "noEmit": true,
+ "rootDir": "./src",
+ "types": ["vitest/globals"]
+ },
+ "include": ["src", "src/middleware/jwk/keys.test.json"]
+}
DescriptionThis pull request introduces a number of changes, primarily to TypeScript configuration and test files. The purpose is to clean up and structure the project more effectively by reorganizing TypeScript configuration into a modular setup and improving the test suites. Specific updates include:
Possible Issues
Security Hotspots
Privacy Hotspots
ChangesChanges
|
mihaiplesa
approved these changes
Mar 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps hono from 4.12.5 to 4.12.7.
Release notes
Sourced from hono's releases.
Commits
b0aba5b4.12.71be3a53ci: apply automated fixesef90225Merge commit from fork3f886364.12.653b66aefix(lambda-edge): avoid callback handler deprecation on NODEJS_24_X (#4782)58825a7feat(jsx-renderer): support function-based options (#4780)0e80acbchore: addtsconfig.spec.json(#4798)d69deb8chore(builld): tsconfig project references (#4797)8217d9efix(jsx): align link hoisting and dedupe with React 19 (#4792)5086956fix(accept): replace regex split to mitigate ReDoS (#4758)Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting
@dependabot rebase.Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
@dependabot rebasewill rebase this PR@dependabot recreatewill recreate this PR, overwriting any edits that have been made to it@dependabot show <dependency name> ignore conditionswill show all of the ignore conditions of the specified dependency@dependabot ignore this major versionwill close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this minor versionwill close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this dependencywill close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)You can disable automated security fix PRs for this repo from the Security Alerts page.