diff --git a/package.json b/package.json
index 24c3039fe2..4f0ad8e1ff 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hono",
- "version": "4.11.8",
+ "version": "4.11.9",
"description": "Web framework built on Web Standards",
"main": "dist/cjs/index.js",
"type": "module",
diff --git a/src/jsx/dom/index.test.tsx b/src/jsx/dom/index.test.tsx
index 23a4f82eb7..2952852bc3 100644
--- a/src/jsx/dom/index.test.tsx
+++ b/src/jsx/dom/index.test.tsx
@@ -309,6 +309,157 @@ describe('DOM', () => {
expect(root.innerHTML).toBe('
2
1
')
expect(Child).toBeCalledTimes(3)
})
+
+ it('multiple children', async () => {
+ const Child = ({ name }: { name: string }) => {
+ const [count, setCount] = useState(0)
+ return (
+
+
+ {name} {count}
+
+
+
+ )
+ }
+ const App = () => {
+ const [count, setCount] = useState(0)
+ return (
+
+
parent {count}
+
+
+
+
+
+
+
+ )
+ }
+ render(, root)
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ const [parentButton, child1Button, child2Button, child3Button] =
+ root.querySelectorAll('button')
+ parentButton?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ child2Button?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ child1Button?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ child3Button?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ })
+
+ it('keeps sibling order when a null sibling exists after parent update', async () => {
+ const Empty = () => null
+ const Child = () => {
+ const [count, setCount] = useState(0)
+ return count === 0 ? (
+ <>
+ A0
+
+ >
+ ) : (
+ <>
+ A1
+ A2
+ >
+ )
+ }
+ const App = () => {
+ const [count, setCount] = useState(0)
+ return (
+ <>
+
+
+ T{count}
+
+ >
+ )
+ }
+ render(, root)
+ expect(root.innerHTML).toBe(
+ 'A0
T0
'
+ )
+ root.querySelector('#parent')?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ 'A0
T1
'
+ )
+ root.querySelector('#child')?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ 'A1A2T1
'
+ )
+ })
+
+ it('multiple children with dynamic addition and rerender', async () => {
+ const Child = ({ name }: { name: string }) => {
+ const [count, setCount] = useState(0)
+ return (
+
+
+ {name} {count}
+
+
+
+ )
+ }
+ const App = () => {
+ const [showThird, setShowThird] = useState(false)
+ return (
+
+
+
+ {showThird && }
+
+
+ )
+ }
+ render(, root)
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ // add child 3
+ let buttons = root.querySelectorAll('button')
+ buttons[2]?.click() // add
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ // click child 1
+ buttons = root.querySelectorAll('button')
+ buttons[0]?.click() // child 1
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ // click child 2 - verify child 2 and child 3 do not swap positions
+ buttons = root.querySelectorAll('button')
+ buttons[1]?.click() // child 2
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ ''
+ )
+ })
})
describe('defaultProps', () => {
diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts
index 26aeb17086..145b6ac31a 100644
--- a/src/jsx/dom/render.ts
+++ b/src/jsx/dom/render.ts
@@ -302,15 +302,11 @@ const getNextChildren = (
})
}
-const findInsertBefore = (node: Node | undefined): SupportedElement | Text | null => {
- for (; ; node = node.tag === HONO_PORTAL_ELEMENT || !node.vC || !node.pP ? node.nN : node.vC[0]) {
- if (!node) {
- return null
- }
- if (node.tag !== HONO_PORTAL_ELEMENT && node.e) {
- return node.e
- }
+const findInsertBefore = (node: Node | undefined): SupportedElement | Text | undefined => {
+ while (node && (node.tag === HONO_PORTAL_ELEMENT || !node.e)) {
+ node = node.tag === HONO_PORTAL_ELEMENT || !node.vC?.[0] ? node.nN : node.vC[0]
}
+ return node?.e
}
const removeNode = (node: Node): void => {
@@ -343,7 +339,7 @@ const apply = (node: NodeObject, container: Container, isNew: boolean): void =>
const findChildNodeIndex = (
childNodes: NodeListOf,
- child: ChildNode | null | undefined
+ child: ChildNode | undefined
): number | undefined => {
if (!child) {
return
@@ -428,7 +424,7 @@ const applyNodeObject = (node: NodeObject, container: Container, isNew: boolean)
}
}
if (node.pP) {
- delete node.pP
+ node.pP = undefined
}
if (callbacks.length) {
const useLayoutEffectCbs: Array<() => void> = []
diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts
index e23ed346ae..c30213c079 100644
--- a/src/utils/url.test.ts
+++ b/src/utils/url.test.ts
@@ -125,6 +125,52 @@ describe('url', () => {
])('getPath - %s', (url) => {
expect(getPath(new Request(url))).toBe(new URL(url).pathname)
})
+
+ it('getPath - with fragment', () => {
+ let path = getPath(new Request('https://example.com/users/#user-list'))
+ expect(path).toBe('/users/')
+ path = getPath(new Request('https://example.com/users/1#profile-section'))
+ expect(path).toBe('/users/1')
+ path = getPath(new Request('https://example.com/hello#section'))
+ expect(path).toBe('/hello')
+ path = getPath(new Request('https://example.com/#top'))
+ expect(path).toBe('/')
+ })
+
+ it('getPath - with query and fragment', () => {
+ let path = getPath(new Request('https://example.com/hello?name=foo#section'))
+ expect(path).toBe('/hello')
+ path = getPath(new Request('https://example.com/search?q=test#results'))
+ expect(path).toBe('/search')
+ })
+
+ it('getPath - with percent encoding only (no query or fragment)', () => {
+ const path = getPath(new Request('https://example.com/hello%20world'))
+ expect(path).toBe('/hello world')
+ })
+
+ it('getPath - with percent encoding and fragment', () => {
+ let path = getPath(new Request('https://example.com/hello%20world#section'))
+ expect(path).toBe('/hello world')
+ path = getPath(new Request('https://example.com/%E7%82%8E#top'))
+ expect(path).toBe('/炎')
+ })
+
+ it('getPath - with percent encoding and fragment containing query-like chars', () => {
+ const path = getPath(new Request('https://example.com/hello%20world#section?foo=bar'))
+ expect(path).toBe('/hello world')
+ })
+
+ it('getPath - with encoded hash (%23) in path and real fragment', () => {
+ // %23 is encoded '#' - decodeURI preserves reserved characters, so %23 stays as %23
+ let path = getPath(new Request('https://example.com/path%23test#real-fragment'))
+ expect(path).toBe('/path%23test')
+ path = getPath(new Request('https://example.com/foo%23bar%23baz#section'))
+ expect(path).toBe('/foo%23bar%23baz')
+ // Only encoded hash, no real fragment
+ path = getPath(new Request('https://example.com/issue%23123'))
+ expect(path).toBe('/issue%23123')
+ })
})
describe('getQueryStrings', () => {
diff --git a/src/utils/url.ts b/src/utils/url.ts
index 82350aa9c3..6f39eaed41 100644
--- a/src/utils/url.ts
+++ b/src/utils/url.ts
@@ -111,13 +111,22 @@ export const getPath = (request: Request): string => {
const charCode = url.charCodeAt(i)
if (charCode === 37) {
// '%'
- // If the path contains percent encoding, use `indexOf()` to find '?' and return the result immediately.
+ // If the path contains percent encoding, use `indexOf()` to find '?' or '#' and return the result immediately.
// Although this is a performance disadvantage, it is acceptable since we prefer cases that do not include percent encoding.
const queryIndex = url.indexOf('?', i)
- const path = url.slice(start, queryIndex === -1 ? undefined : queryIndex)
+ const hashIndex = url.indexOf('#', i)
+ const end =
+ queryIndex === -1
+ ? hashIndex === -1
+ ? undefined
+ : hashIndex
+ : hashIndex === -1
+ ? queryIndex
+ : Math.min(queryIndex, hashIndex)
+ const path = url.slice(start, end)
return tryDecodeURI(path.includes('%25') ? path.replace(/%25/g, '%2525') : path)
- } else if (charCode === 63) {
- // '?'
+ } else if (charCode === 63 || charCode === 35) {
+ // '?' or '#'
break
}
}