From 0c1d4c76cf6b2aace8bbef745d375c2cc176d99f Mon Sep 17 00:00:00 2001 From: Sano Suguru <43309177+sano-suguru@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:50:31 +0900 Subject: [PATCH 1/3] fix(url): ignore fragment identifiers in getPath() (#4627) * fix(url): ignore fragment identifiers in getPath() In Service Worker contexts, URLs may contain fragment identifiers (#) which should not be included in route matching. This change modifies getPath() to strip fragment identifiers from the path, consistent with how query strings are already handled. Fixes #4440 * fix: handle fragment before query in percent-encoded paths When a URL contains a fragment with query-like characters (e.g., #section?foo=bar), the previous logic would incorrectly include the fragment in the path because it checked for '?' first and only looked for '#' if '?' was not found. This fix uses Math.min() to find whichever delimiter ('?' or '#') comes first, correctly handling edge cases per RFC 3986 where '#' terminates the path regardless of subsequent '?' characters in the fragment. Added test case for this edge case. * test(url): add test for encoded hash (%23) in path with fragment Verify that percent-encoded hash (%23) is preserved while real fragment identifiers are correctly stripped. decodeURI preserves reserved characters per Web Standards. * perf(url): reduce === -1 evaluations in getPath Restructure ternary to avoid redundant separator === -1 check. For /path?x=1, reduces evaluations from 3 to 2. Co-authored-by: usualoma * test(url): add coverage for percent encoding without query or fragment Cover the undefined branch when both queryIndex and hashIndex are -1. --------- Co-authored-by: sanosuguru Co-authored-by: usualoma --- src/utils/url.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++++ src/utils/url.ts | 17 ++++++++++++---- 2 files changed, 59 insertions(+), 4 deletions(-) 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 } } From 3d536ff38d5c24ca584866a7f01cf5691b96e983 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 8 Feb 2026 21:57:27 +0900 Subject: [PATCH 2/3] fix: determine if rendered or not by `node.vC[0]` instead of referring to `node.pP` (#4663) * fix: determine if rendered or not by node.vC[0] instead of referring to node.pP * refactor: assign undefined to "pP" instead of deleting it Because that is slightly more likely to be optimized by the JIT * refactor: use while loop instead of for loop for shorter code --- src/jsx/dom/index.test.tsx | 151 +++++++++++++++++++++++++++++++++++++ src/jsx/dom/render.ts | 16 ++-- 2 files changed, 157 insertions(+), 10 deletions(-) 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( + '
parent 0
child 1 0
child 2 0
child 3 0
' + ) + const [parentButton, child1Button, child2Button, child3Button] = + root.querySelectorAll('button') + parentButton?.click() + await Promise.resolve() + expect(root.innerHTML).toBe( + '
parent 1
child 1 0
child 2 0
child 3 0
' + ) + child2Button?.click() + await Promise.resolve() + expect(root.innerHTML).toBe( + '
parent 1
child 1 0
child 2 1
child 3 0
' + ) + child1Button?.click() + await Promise.resolve() + expect(root.innerHTML).toBe( + '
parent 1
child 1 1
child 2 1
child 3 0
' + ) + child3Button?.click() + await Promise.resolve() + expect(root.innerHTML).toBe( + '
parent 1
child 1 1
child 2 1
child 3 1
' + ) + }) + + 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( + 'A1A2
T1
' + ) + }) + + 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( + '
child 1 0
child 2 0
' + ) + // add child 3 + let buttons = root.querySelectorAll('button') + buttons[2]?.click() // add + await Promise.resolve() + expect(root.innerHTML).toBe( + '
child 1 0
child 2 0
child 3 0
' + ) + // click child 1 + buttons = root.querySelectorAll('button') + buttons[0]?.click() // child 1 + await Promise.resolve() + expect(root.innerHTML).toBe( + '
child 1 1
child 2 0
child 3 0
' + ) + // 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( + '
child 1 1
child 2 1
child 3 0
' + ) + }) }) 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> = [] From 69ad8857df4eeef1a02e628ab8f5b2b60e643f19 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sun, 8 Feb 2026 22:00:11 +0900 Subject: [PATCH 3/3] 4.11.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",