0
0
mirror of https://github.com/honojs/hono.git synced 2024-11-21 18:18:57 +01:00

feat(jsx/dom): skip calculate children if props are the same (#3049)

* feat(jsx/dom): skip build children if props are the same

* test: fix format

* fix: re-calculate form element if state is updated
This commit is contained in:
Taku Amano 2024-06-29 17:24:39 +09:00 committed by GitHub
parent cb79f2302b
commit a8a84f3055
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 65 additions and 21 deletions

View File

@ -1,6 +1,6 @@
/** @jsxImportSource ../../ */
import { JSDOM } from 'jsdom'
import { render, useState } from '..'
import { render, useCallback, useState } from '..'
import { useActionState, useFormStatus, useOptimistic } from '.'
describe('Hooks', () => {
@ -84,14 +84,13 @@ describe('Hooks', () => {
}
const App = () => {
const [, setCount] = useState(0)
const action = useCallback(() => {
setCount((count) => count + 1)
return formPromise
}, [])
return (
<>
<form
action={() => {
setCount((count) => count + 1)
return formPromise
}}
>
<form action={action}>
<Status />
<input type='text' name='name' value='updated' />
<button>Submit</button>
@ -134,15 +133,15 @@ describe('Hooks', () => {
const App = () => {
const [count, setCount] = useState(0)
const [optimisticCount, setOptimisticCount] = useOptimistic(count, (c, n: number) => n)
const action = useCallback(async () => {
setOptimisticCount(count + 1)
await formPromise
setCount((count) => count + 2)
}, [])
return (
<>
<form
action={async () => {
setOptimisticCount(count + 1)
await formPromise
setCount((count) => count + 2)
}}
>
<form action={action}>
<div>{optimisticCount}</div>
<input type='text' name='name' value='updated' />
<button>Submit</button>

View File

@ -264,6 +264,33 @@ describe('DOM', () => {
})
})
describe('skip build child', () => {
it('simple', async () => {
const Child = vi.fn(({ count }: { count: number }) => <div>{count}</div>)
const App = () => {
const [count, setCount] = useState(0)
return (
<>
<div>{count}</div>
<Child count={Math.floor(count / 2)} />
<button onClick={() => setCount(count + 1)}>+</button>
</>
)
}
render(<App />, root)
expect(root.innerHTML).toBe('<div>0</div><div>0</div><button>+</button>')
expect(Child).toBeCalledTimes(1)
root.querySelector('button')?.click()
await Promise.resolve()
expect(root.innerHTML).toBe('<div>1</div><div>0</div><button>+</button>')
expect(Child).toBeCalledTimes(1)
root.querySelector('button')?.click()
await Promise.resolve()
expect(root.innerHTML).toBe('<div>2</div><div>1</div><button>+</button>')
expect(Child).toBeCalledTimes(2)
})
})
describe('defaultProps', () => {
it('simple', () => {
const App: FC<{ name?: string }> = ({ name }) => <div>{name}</div>

View File

@ -277,7 +277,7 @@ export const form: FC<
;(restProps as any).action = action
}
const [data, setData] = useState<FormData | null>(null)
const [state, setState] = useState<[FormData | null, boolean]>([null, false]) // [FormData, isDirty]
const onSubmit = useCallback<(ev: SubmitEvent | CustomEvent) => void>(
async (ev: SubmitEvent | CustomEvent) => {
const currentAction = ev.isTrusted
@ -289,13 +289,13 @@ export const form: FC<
ev.preventDefault()
const formData = new FormData(ev.target as HTMLFormElement)
setData(formData)
setState([formData, true])
const actionRes = currentAction(formData)
if (actionRes instanceof Promise) {
registerAction(actionRes)
await actionRes
}
setData(null)
setState([null, true])
},
[]
)
@ -307,6 +307,8 @@ export const form: FC<
}
})
const [data, isDirty] = state
state[1] = false
return newJSXNode({
tag: FormContext as unknown as Function,
props: {
@ -324,8 +326,9 @@ export const form: FC<
},
}),
},
f: isDirty,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any
} as any) as any
}
const formActionableElement = (

View File

@ -43,6 +43,7 @@ export type NodeObject = {
vR: Node[] // virtual dom children to remove
s?: Node[] // shadow virtual dom children
n?: string // namespace
f?: boolean // force build
c: Container | undefined // container
e: SupportedElement | Text | undefined // rendered element
p?: PreserveNodeType // preserve HTMLElement if it will be unmounted
@ -444,6 +445,7 @@ export const build = (context: Context, node: NodeObject, children?: Child[]): v
oldVChildren.splice(i, 1)
}
let skipBuild = false
if (oldChild) {
if (isNodeString(child)) {
if ((oldChild as NodeString).t !== child.t) {
@ -454,11 +456,20 @@ export const build = (context: Context, node: NodeObject, children?: Child[]): v
} else if (oldChild.tag !== child.tag) {
node.vR.push(oldChild)
} else {
oldChild.pP = oldChild.props
const pP = (oldChild.pP = oldChild.props)
oldChild.props = child.props
oldChild.f ||= child.f || node.f
if (typeof child.tag === 'function') {
oldChild[DOM_STASH][2] = child[DOM_STASH][2] || []
oldChild[DOM_STASH][3] = child[DOM_STASH][3]
if (!oldChild.f) {
const prevPropsKeys = Object.keys(pP)
const currentProps = oldChild.props
skipBuild =
prevPropsKeys.length === Object.keys(currentProps).length &&
prevPropsKeys.every((k) => k in currentProps && currentProps[k] === pP[k])
}
}
child = oldChild
}
@ -469,8 +480,9 @@ export const build = (context: Context, node: NodeObject, children?: Child[]): v
}
}
if (!isNodeString(child)) {
if (!isNodeString(child) && !skipBuild) {
build(context, child)
delete child.f
}
vChildren.push(child)
@ -486,6 +498,7 @@ export const build = (context: Context, node: NodeObject, children?: Child[]): v
delete node.pC
}
} catch (e) {
node.f = true
if (e === cancelBuild) {
if (foundErrorHandler) {
return
@ -552,7 +565,9 @@ export const buildNode = (node: Child): Node | undefined => {
tag: (node as NodeObject).tag,
props: (node as NodeObject).props,
key: (node as NodeObject).key,
})
f: (node as NodeObject).f,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
if (typeof (node as JSXNode).tag === 'function') {
;(node as NodeObject)[DOM_STASH] = [0, []]