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( + '
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> = [] 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 } }