Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
151 changes: 151 additions & 0 deletions src/jsx/dom/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,157 @@ describe('DOM', () => {
expect(root.innerHTML).toBe('<div>2</div><div>1</div><button>+</button>')
expect(Child).toBeCalledTimes(3)
})

it('multiple children', async () => {
const Child = ({ name }: { name: string }) => {
const [count, setCount] = useState(0)
return (
<div>
<div>
{name} {count}
</div>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
}
const App = () => {
const [count, setCount] = useState(0)
return (
<div>
<div>parent {count}</div>
<button onClick={() => setCount(count + 1)}>+</button>
<div>
<Child name='child 1' />
<Child name='child 2' />
<Child name='child 3' />
</div>
</div>
)
}
render(<App />, root)
expect(root.innerHTML).toBe(
'<div><div>parent 0</div><button>+</button><div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div></div></div>'
)
const [parentButton, child1Button, child2Button, child3Button] =
root.querySelectorAll('button')
parentButton?.click()
await Promise.resolve()
expect(root.innerHTML).toBe(
'<div><div>parent 1</div><button>+</button><div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div></div></div>'
)
child2Button?.click()
await Promise.resolve()
expect(root.innerHTML).toBe(
'<div><div>parent 1</div><button>+</button><div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 1</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div></div></div>'
)
child1Button?.click()
await Promise.resolve()
expect(root.innerHTML).toBe(
'<div><div>parent 1</div><button>+</button><div><div><div>child 1 1</div><button>+</button></div><div><div>child 2 1</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div></div></div>'
)
child3Button?.click()
await Promise.resolve()
expect(root.innerHTML).toBe(
'<div><div>parent 1</div><button>+</button><div><div><div>child 1 1</div><button>+</button></div><div><div>child 2 1</div><button>+</button></div><div><div>child 3 1</div><button>+</button></div></div></div>'
)
})

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 ? (
<>
<div>A0</div>
<button id='child' onClick={() => setCount(1)}>
child+
</button>
</>
) : (
<>
<span>A1</span>
<span>A2</span>
</>
)
}
const App = () => {
const [count, setCount] = useState(0)
return (
<>
<Child />
<Empty />
<div id='tail'>T{count}</div>
<button id='parent' onClick={() => setCount(count + 1)}>
parent+
</button>
</>
)
}
render(<App />, root)
expect(root.innerHTML).toBe(
'<div>A0</div><button id="child">child+</button><div id="tail">T0</div><button id="parent">parent+</button>'
)
root.querySelector<HTMLButtonElement>('#parent')?.click()
await Promise.resolve()
expect(root.innerHTML).toBe(
'<div>A0</div><button id="child">child+</button><div id="tail">T1</div><button id="parent">parent+</button>'
)
root.querySelector<HTMLButtonElement>('#child')?.click()
await Promise.resolve()
expect(root.innerHTML).toBe(
'<span>A1</span><span>A2</span><div id="tail">T1</div><button id="parent">parent+</button>'
)
})

it('multiple children with dynamic addition and rerender', async () => {
const Child = ({ name }: { name: string }) => {
const [count, setCount] = useState(0)
return (
<div>
<div>
{name} {count}
</div>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
}
const App = () => {
const [showThird, setShowThird] = useState(false)
return (
<div>
<Child name='child 1' />
<Child name='child 2' />
{showThird && <Child name='child 3' />}
<button onClick={() => setShowThird(true)}>add</button>
</div>
)
}
render(<App />, root)
expect(root.innerHTML).toBe(
'<div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><button>add</button></div>'
)
// add child 3
let buttons = root.querySelectorAll('button')
buttons[2]?.click() // add
await Promise.resolve()
expect(root.innerHTML).toBe(
'<div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div><button>add</button></div>'
)
// click child 1
buttons = root.querySelectorAll('button')
buttons[0]?.click() // child 1
await Promise.resolve()
expect(root.innerHTML).toBe(
'<div><div><div>child 1 1</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div><button>add</button></div>'
)
// 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(
'<div><div><div>child 1 1</div><button>+</button></div><div><div>child 2 1</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div><button>add</button></div>'
)
})
})

describe('defaultProps', () => {
Expand Down
16 changes: 6 additions & 10 deletions src/jsx/dom/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -343,7 +339,7 @@ const apply = (node: NodeObject, container: Container, isNew: boolean): void =>

const findChildNodeIndex = (
childNodes: NodeListOf<ChildNode>,
child: ChildNode | null | undefined
child: ChildNode | undefined
): number | undefined => {
if (!child) {
return
Expand Down Expand Up @@ -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> = []
Expand Down
46 changes: 46 additions & 0 deletions src/utils/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 13 additions & 4 deletions src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Loading