diff --git a/deno_dist/jsx/dom/index.ts b/deno_dist/jsx/dom/index.ts index 05a829486..f7b252b77 100644 --- a/deno_dist/jsx/dom/index.ts +++ b/deno_dist/jsx/dom/index.ts @@ -15,6 +15,7 @@ import { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -79,6 +80,7 @@ export { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -114,6 +116,7 @@ export default { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, diff --git a/deno_dist/jsx/dom/jsx-dev-runtime.ts b/deno_dist/jsx/dom/jsx-dev-runtime.ts index fc1837012..f356271e2 100644 --- a/deno_dist/jsx/dom/jsx-dev-runtime.ts +++ b/deno_dist/jsx/dom/jsx-dev-runtime.ts @@ -1,31 +1,16 @@ import type { Props, JSXNode } from '../base.ts' import { normalizeIntrinsicElementProps } from '../utils.ts' - -const JSXNodeCompatPrototype = { - type: { - get(this: { tag: string | Function }): string | Function { - return this.tag - }, - }, - ref: { - get(this: { props?: { ref: unknown } }): unknown { - return this.props?.ref - }, - }, -} +import { newJSXNode } from './utils.ts' export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXNode => { if (typeof tag === 'string') { normalizeIntrinsicElementProps(props) } - return Object.defineProperties( - { - tag, - props, - key, - }, - JSXNodeCompatPrototype - ) as JSXNode + return newJSXNode({ + tag, + props, + key, + }) } export const Fragment = (props: Record): JSXNode => jsxDEV('', props, undefined) diff --git a/deno_dist/jsx/dom/render.ts b/deno_dist/jsx/dom/render.ts index efeefd6a0..a19c8b11c 100644 --- a/deno_dist/jsx/dom/render.ts +++ b/deno_dist/jsx/dom/render.ts @@ -8,6 +8,7 @@ import type { EffectData } from '../hooks/index.ts' import { STASH_EFFECT } from '../hooks/index.ts' import { styleObjectForEach } from '../utils.ts' import { createContext } from './context.ts' // import dom-specific versions +import { newJSXNode } from './utils.ts' const HONO_PORTAL_ELEMENT = '_hp' @@ -106,6 +107,14 @@ const getEventSpec = (key: string): [string, boolean] | undefined => { return undefined } +const toAttributeName = (element: SupportedElement, key: string): string => + element instanceof SVGElement && + /[A-Z]/.test(key) && + (key in element.style || // Presentation attributes are findable in style object. "clip-path", "font-size", "stroke-width", etc. + key.match(/^(?:o|pai|str|u|ve)/)) // Other un-deprecated kebab-case attributes. "overline-position", "paint-order", "strikethrough-position", etc. + ? key.replace(/([A-Z])/g, '-$1').toLowerCase() + : key + const applyProps = (container: SupportedElement, attributes: Props, oldAttributes?: Props) => { attributes ||= {} for (const [key, value] of Object.entries(attributes)) { @@ -164,14 +173,16 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute ;(container as any)[key] = value } + const k = toAttributeName(container, key) + if (value === null || value === undefined || value === false) { - container.removeAttribute(key) + container.removeAttribute(k) } else if (value === true) { - container.setAttribute(key, '') + container.setAttribute(k, '') } else if (typeof value === 'string' || typeof value === 'number') { - container.setAttribute(key, value as string) + container.setAttribute(k, value as string) } else { - container.setAttribute(key, value.toString()) + container.setAttribute(k, value.toString()) } } } @@ -189,7 +200,7 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute value.current = null } } else { - container.removeAttribute(key) + container.removeAttribute(toAttributeName(container, key)) } } } @@ -248,6 +259,8 @@ const getNextChildren = ( const findInsertBefore = (node: Node | undefined): ChildNode | null => { if (!node) { return null + } else if (node.tag === HONO_PORTAL_ELEMENT) { + return findInsertBefore(node.nN) } else if (node.e) { return node.e } @@ -325,7 +338,7 @@ const applyNodeObject = (node: NodeObject, container: Container) => { const childNodes = container.childNodes let offset = findChildNodeIndex(childNodes, findInsertBefore(node.nN)) ?? - findChildNodeIndex(childNodes, next.find((n) => n.e)?.e) ?? + findChildNodeIndex(childNodes, next.find((n) => n.tag !== HONO_PORTAL_ELEMENT && n.e)?.e) ?? childNodes.length for (let i = 0, len = next.length; i < len; i++, offset++) { @@ -345,18 +358,17 @@ const applyNodeObject = (node: NodeObject, container: Container) => { applyProps(el as HTMLElement, child.props, child.pP) applyNode(child, el as HTMLElement) } - if ( - childNodes[offset] !== el && - childNodes[offset - 1] !== child.e && - child.tag !== HONO_PORTAL_ELEMENT - ) { + if (child.tag === HONO_PORTAL_ELEMENT) { + offset-- + } else if (childNodes[offset] !== el && childNodes[offset - 1] !== child.e) { container.insertBefore(el, childNodes[offset] || null) } } remove.forEach(removeNode) - callbacks.forEach(([, cb]) => cb?.()) + callbacks.forEach(([, , , , cb]) => cb?.()) // invoke useInsertionEffect callbacks + callbacks.forEach(([, cb]) => cb?.()) // invoke useLayoutEffect callbacks requestAnimationFrame(() => { - callbacks.forEach(([, , , cb]) => cb?.()) + callbacks.forEach(([, , , cb]) => cb?.()) // invoke useEffect callbacks }) } @@ -380,7 +392,7 @@ export const build = ( } const oldVChildren: Node[] = node.vC ? [...node.vC] : [] const vChildren: Node[] = [] - const vChildrenToRemove: Node[] = [] + node.vR = [] let prevNode: Node | undefined try { children.flat().forEach((c: Child) => { @@ -401,7 +413,13 @@ export const build = ( } let oldChild: Node | undefined - const i = oldVChildren.findIndex((c) => c.key === (child as Node).key) + const i = oldVChildren.findIndex( + isNodeString(child) + ? (c) => isNodeString(c) + : child.key !== undefined + ? (c) => c.key === (child as Node).key + : (c) => c.tag === (child as Node).tag + ) if (i !== -1) { oldChild = oldVChildren[i] oldVChildren.splice(i, 1) @@ -409,17 +427,13 @@ export const build = ( if (oldChild) { if (isNodeString(child)) { - if (!isNodeString(oldChild)) { - vChildrenToRemove.push(oldChild) - } else { - if (oldChild.t !== child.t) { - oldChild.t = child.t // update text content - oldChild.d = true - } - child = oldChild + if ((oldChild as NodeString).t !== child.t) { + ;(oldChild as NodeString).t = child.t // update text content + ;(oldChild as NodeString).d = true } + child = oldChild } else if (oldChild.tag !== child.tag) { - vChildrenToRemove.push(oldChild) + node.vR.push(oldChild) } else { oldChild.pP = oldChild.props oldChild.props = child.props @@ -442,8 +456,7 @@ export const build = ( } }) node.vC = vChildren - vChildrenToRemove.push(...oldVChildren) - node.vR = vChildrenToRemove + node.vR.push(...oldVChildren) } catch (e) { if (errorHandler) { const fallbackUpdateFn = () => @@ -481,10 +494,14 @@ const buildNode = (node: Child): Node | undefined => { } else if (typeof node === 'string' || typeof node === 'number') { return { t: node.toString(), d: true } as NodeString } else { + if ('vR' in node) { + node = newJSXNode({ + tag: (node as NodeObject).tag, + props: (node as NodeObject).props, + key: (node as NodeObject).key, + }) + } if (typeof (node as JSXNode).tag === 'function') { - if ((node as NodeObject)[DOM_STASH]) { - node = { ...node } as NodeObject - } ;(node as NodeObject)[DOM_STASH] = [0, []] } else { const ns = nameSpaceMap[(node as JSXNode).tag as string] diff --git a/deno_dist/jsx/dom/utils.ts b/deno_dist/jsx/dom/utils.ts index 803652155..020c45ac9 100644 --- a/deno_dist/jsx/dom/utils.ts +++ b/deno_dist/jsx/dom/utils.ts @@ -1,3 +1,4 @@ +import type { Props, JSXNode } from '../base.ts' import { DOM_INTERNAL_TAG } from '../constants.ts' export const setInternalTagFlag = (fn: Function): Function => { @@ -5,3 +6,19 @@ export const setInternalTagFlag = (fn: Function): Function => { ;(fn as any)[DOM_INTERNAL_TAG] = true return fn } + +const JSXNodeCompatPrototype = { + type: { + get(this: { tag: string | Function }): string | Function { + return this.tag + }, + }, + ref: { + get(this: { props?: { ref: unknown } }): unknown { + return this.props?.ref + }, + }, +} + +export const newJSXNode = (obj: { tag: string | Function; props?: Props; key?: string }): JSXNode => + Object.defineProperties(obj, JSXNodeCompatPrototype) as JSXNode diff --git a/deno_dist/jsx/hooks/index.ts b/deno_dist/jsx/hooks/index.ts index 59df5e344..5ae294b1d 100644 --- a/deno_dist/jsx/hooks/index.ts +++ b/deno_dist/jsx/hooks/index.ts @@ -15,7 +15,8 @@ export type EffectData = [ readonly unknown[] | undefined, // deps (() => void | (() => void)) | undefined, // layout effect (() => void) | undefined, // cleanup - (() => void) | undefined // effect + (() => void) | undefined, // effect + (() => void) | undefined // insertion effect ] const resolvedPromiseValueMap: WeakMap, unknown> = new WeakMap< @@ -251,7 +252,7 @@ const useEffectCommon = ( data[index] = undefined // clear this effect in order to avoid calling effect twice data[2] = effect() as (() => void) | undefined } - const data: EffectData = [deps, undefined, undefined, undefined] + const data: EffectData = [deps, undefined, undefined, undefined, undefined] data[index] = runner effectDepsArray[hookIndex] = data } @@ -262,6 +263,10 @@ export const useLayoutEffect = ( effect: () => void | (() => void), deps?: readonly unknown[] ): void => useEffectCommon(1, effect, deps) +export const useInsertionEffect = ( + effect: () => void | (() => void), + deps?: readonly unknown[] +): void => useEffectCommon(4, effect, deps) export const useCallback = unknown>( callback: T, diff --git a/deno_dist/jsx/index.ts b/deno_dist/jsx/index.ts index 005c04b96..e5e07401b 100644 --- a/deno_dist/jsx/index.ts +++ b/deno_dist/jsx/index.ts @@ -16,6 +16,7 @@ import { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -51,6 +52,7 @@ export { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, createRef, forwardRef, useImperativeHandle, @@ -84,6 +86,7 @@ export default { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, createRef, forwardRef, useImperativeHandle, diff --git a/src/jsx/dom/css.test.tsx b/src/jsx/dom/css.test.tsx index 97ba4ae0e..82430174c 100644 --- a/src/jsx/dom/css.test.tsx +++ b/src/jsx/dom/css.test.tsx @@ -22,6 +22,7 @@ describe('Style and css for jsx/dom', () => { }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement + global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) diff --git a/src/jsx/dom/index.test.tsx b/src/jsx/dom/index.test.tsx index f300b6f16..c9d4d6014 100644 --- a/src/jsx/dom/index.test.tsx +++ b/src/jsx/dom/index.test.tsx @@ -9,6 +9,7 @@ import { useState, useEffect, useLayoutEffect, + useInsertionEffect, useCallback, useRef, useMemo, @@ -89,6 +90,7 @@ describe('DOM', () => { }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement + global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) @@ -414,6 +416,42 @@ describe('DOM', () => { }) }) + describe('children', () => { + it('element', async () => { + const Container = ({ children }: { children: Child }) =>
{children}
+ const App = () => ( + + Content + + ) + render(, root) + expect(root.innerHTML).toBe('
Content
') + }) + + it('array', async () => { + const Container = ({ children }: { children: Child }) =>
{children}
+ const App = () => {[1, 2]} + render(, root) + expect(root.innerHTML).toBe('
12
') + }) + + it('use the same children multiple times', async () => { + const MultiChildren = ({ children }: { children: Child }) => ( + <> + {children} +
{children}
+ + ) + const App = () => ( + + Content + + ) + render(, root) + expect(root.innerHTML).toBe('Content
Content
') + }) + }) + describe('update properties', () => { describe('input', () => { it('value', async () => { @@ -1060,6 +1098,45 @@ describe('DOM', () => { ) }) + it('swap deferent type of child component', async () => { + const Even = () =>

Even

+ const Odd = () =>
Odd
+ const Counter = () => { + const [count, setCount] = useState(0) + return ( +
+ {count % 2 === 0 ? ( + <> + + + + ) : ( + <> + + + + )} + +
+ ) + } + const app = + render(app, root) + expect(root.innerHTML).toBe('

Even

Odd
') + const button = root.querySelector('button') as HTMLButtonElement + + const createElementSpy = vi.spyOn(dom.window.document, 'createElement') + + button.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
Odd

Even

') + button.click() + await Promise.resolve() + expect(root.innerHTML).toBe('

Even

Odd
') + + expect(createElementSpy).not.toHaveBeenCalled() + }) + it('setState for unnamed function', async () => { const Input = ({ label, onInput }: { label: string; onInput: (value: string) => void }) => { return ( @@ -1433,6 +1510,120 @@ describe('DOM', () => { }) }) + describe('useInsertionEffect', () => { + it('simple', async () => { + const Counter = () => { + const [count, setCount] = useState(0) + useInsertionEffect(() => { + setCount(count + 1) + }, []) + return
{count}
+ } + const app = + render(app, root) + await Promise.resolve() + expect(root.innerHTML).toBe('
1
') + }) + + it('multiple', async () => { + const Counter = () => { + const [count, setCount] = useState(0) + useInsertionEffect(() => { + setCount((c) => c + 1) + }, []) + useInsertionEffect(() => { + setCount((c) => c + 1) + }, []) + return
{count}
+ } + const app = + render(app, root) + await Promise.resolve() + expect(root.innerHTML).toBe('
2
') + }) + + it('with useLayoutEffect', async () => { + const Counter = () => { + const [data, setData] = useState([]) + useLayoutEffect(() => { + setData((d) => [...d, 'useLayoutEffect']) + }, []) + useInsertionEffect(() => { + setData((d) => [...d, 'useInsertionEffect']) + }, []) + return
{data.join(',')}
+ } + const app = + render(app, root) + await Promise.resolve() + expect(root.innerHTML).toBe('
useInsertionEffect,useLayoutEffect
') + }) + + it('cleanup', async () => { + const Child = ({ parent }: { parent: RefObject }) => { + useInsertionEffect(() => { + return () => { + parent.current?.setAttribute('data-cleanup', 'true') + } + }, []) + return
Child
+ } + const Parent = () => { + const [show, setShow] = useState(true) + const ref = useRef(null) + return ( +
+ {show && } + +
+ ) + } + const app = + render(app, root) + expect(root.innerHTML).toBe('
Child
') + const [button] = root.querySelectorAll('button') + button.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
') + }) + + it('cleanup for deps', async () => { + let effectCount = 0 + let cleanupCount = 0 + + const App = () => { + const [count, setCount] = useState(0) + const [count2, setCount2] = useState(0) + useInsertionEffect(() => { + effectCount++ + return () => { + cleanupCount++ + } + }, [count]) + return ( +
+

{count}

+

{count2}

+ + +
+ ) + } + const app = + render(app, root) + expect(effectCount).toBe(1) + expect(cleanupCount).toBe(0) + root.querySelectorAll('button')[0].click() // count++ + await Promise.resolve() + expect(effectCount).toBe(2) + expect(cleanupCount).toBe(1) + root.querySelectorAll('button')[1].click() // count2++ + await Promise.resolve() + expect(effectCount).toBe(2) + expect(cleanupCount).toBe(1) + }) + }) + describe('useCallback', () => { it('deferent callbacks', async () => { const callbackSet = new Set() @@ -1669,6 +1860,43 @@ describe('DOM', () => { await Promise.resolve() expect(document.body.innerHTML).toBe('
') }) + + it('update', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ {createPortal(

{count}

, document.body)} + +
+

{count}

+
+
+ ) + } + const app = + render(app, root) + expect(root.innerHTML).toBe('

0

') + expect(document.body.innerHTML).toBe( + '

0

0

' + ) + + const createElementSpy = vi.spyOn(dom.window.document, 'createElement') + + document.body.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('

1

') + expect(document.body.innerHTML).toBe( + '

1

1

' + ) + document.body.querySelector('button')?.click() + await Promise.resolve() + expect(document.body.innerHTML).toBe( + '

2

2

' + ) + + expect(createElementSpy).not.toHaveBeenCalled() + }) }) describe('SVG', () => { @@ -1704,6 +1932,163 @@ describe('DOM', () => { expect(document.querySelector('title')).toBeInstanceOf(dom.window.HTMLTitleElement) expect(document.querySelector('svg title')).toBeInstanceOf(dom.window.SVGTitleElement) }) + + describe('attribute', () => { + describe('camelCase', () => { + test.each` + key + ${'attributeName'} + ${'baseFrequency'} + ${'calcMode'} + ${'clipPathUnits'} + ${'diffuseConstant'} + ${'edgeMode'} + ${'filterUnits'} + ${'gradientTransform'} + ${'gradientUnits'} + ${'kernelMatrix'} + ${'kernelUnitLength'} + ${'keyPoints'} + ${'keySplines'} + ${'keyTimes'} + ${'lengthAdjust'} + ${'limitingConeAngle'} + ${'markerHeight'} + ${'markerUnits'} + ${'markerWidth'} + ${'maskContentUnits'} + ${'maskUnits'} + ${'numOctaves'} + ${'pathLength'} + ${'patternContentUnits'} + ${'patternTransform'} + ${'patternUnits'} + ${'pointsAtX'} + ${'pointsAtY'} + ${'pointsAtZ'} + ${'preserveAlpha'} + ${'preserveAspectRatio'} + ${'primitiveUnits'} + ${'refX'} + ${'refY'} + ${'repeatCount'} + ${'repeatDur'} + ${'specularConstant'} + ${'specularExponent'} + ${'spreadMethod'} + ${'startOffset'} + ${'stdDeviation'} + ${'stitchTiles'} + ${'surfaceScale'} + ${'crossorigin'} + ${'systemLanguage'} + ${'tableValues'} + ${'targetX'} + ${'targetY'} + ${'textLength'} + ${'viewBox'} + ${'xChannelSelector'} + ${'yChannelSelector'} + `('$key', ({ key }) => { + const App = () => { + return ( + + + + ) + } + render(, root) + expect(root.innerHTML).toBe(``) + }) + }) + + describe('kebab-case', () => { + test.each` + key + ${'alignmentBaseline'} + ${'baselineShift'} + ${'clipPath'} + ${'clipRule'} + ${'colorInterpolation'} + ${'colorInterpolationFilters'} + ${'dominantBaseline'} + ${'fillOpacity'} + ${'fillRule'} + ${'floodColor'} + ${'floodOpacity'} + ${'fontFamily'} + ${'fontSize'} + ${'fontSizeAdjust'} + ${'fontStretch'} + ${'fontStyle'} + ${'fontVariant'} + ${'fontWeight'} + ${'imageRendering'} + ${'letterSpacing'} + ${'lightingColor'} + ${'markerEnd'} + ${'markerMid'} + ${'markerStart'} + ${'overlinePosition'} + ${'overlineThickness'} + ${'paintOrder'} + ${'pointerEvents'} + ${'shapeRendering'} + ${'stopColor'} + ${'stopOpacity'} + ${'strikethroughPosition'} + ${'strikethroughThickness'} + ${'strokeDasharray'} + ${'strokeDashoffset'} + ${'strokeLinecap'} + ${'strokeLinejoin'} + ${'strokeMiterlimit'} + ${'strokeOpacity'} + ${'strokeWidth'} + ${'textAnchor'} + ${'textDecoration'} + ${'textRendering'} + ${'transformOrigin'} + ${'underlinePosition'} + ${'underlineThickness'} + ${'unicodeBidi'} + ${'vectorEffect'} + ${'wordSpacing'} + ${'writingMode'} + `('$key', ({ key }) => { + const App = () => { + return ( + + + + ) + } + render(, root) + expect(root.innerHTML).toBe( + `` + ) + }) + }) + + describe('data-*', () => { + test.each` + key + ${'data-foo'} + ${'data-foo-bar'} + ${'data-fooBar'} + `('$key', ({ key }) => { + const App = () => { + return ( + + + + ) + } + render(, root) + expect(root.innerHTML).toBe(``) + }) + }) + }) }) describe('MathML', () => { diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 3612119da..a308d6557 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -15,6 +15,7 @@ import { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -79,6 +80,7 @@ export { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -114,6 +116,7 @@ export default { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, diff --git a/src/jsx/dom/jsx-dev-runtime.ts b/src/jsx/dom/jsx-dev-runtime.ts index a4c439b16..d7a6ff29e 100644 --- a/src/jsx/dom/jsx-dev-runtime.ts +++ b/src/jsx/dom/jsx-dev-runtime.ts @@ -1,31 +1,16 @@ import type { Props, JSXNode } from '../base' import { normalizeIntrinsicElementProps } from '../utils' - -const JSXNodeCompatPrototype = { - type: { - get(this: { tag: string | Function }): string | Function { - return this.tag - }, - }, - ref: { - get(this: { props?: { ref: unknown } }): unknown { - return this.props?.ref - }, - }, -} +import { newJSXNode } from './utils' export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXNode => { if (typeof tag === 'string') { normalizeIntrinsicElementProps(props) } - return Object.defineProperties( - { - tag, - props, - key, - }, - JSXNodeCompatPrototype - ) as JSXNode + return newJSXNode({ + tag, + props, + key, + }) } export const Fragment = (props: Record): JSXNode => jsxDEV('', props, undefined) diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 35ea01d17..ba3fffe15 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -8,6 +8,7 @@ import type { EffectData } from '../hooks' import { STASH_EFFECT } from '../hooks' import { styleObjectForEach } from '../utils' import { createContext } from './context' // import dom-specific versions +import { newJSXNode } from './utils' const HONO_PORTAL_ELEMENT = '_hp' @@ -106,6 +107,14 @@ const getEventSpec = (key: string): [string, boolean] | undefined => { return undefined } +const toAttributeName = (element: SupportedElement, key: string): string => + element instanceof SVGElement && + /[A-Z]/.test(key) && + (key in element.style || // Presentation attributes are findable in style object. "clip-path", "font-size", "stroke-width", etc. + key.match(/^(?:o|pai|str|u|ve)/)) // Other un-deprecated kebab-case attributes. "overline-position", "paint-order", "strikethrough-position", etc. + ? key.replace(/([A-Z])/g, '-$1').toLowerCase() + : key + const applyProps = (container: SupportedElement, attributes: Props, oldAttributes?: Props) => { attributes ||= {} for (const [key, value] of Object.entries(attributes)) { @@ -164,14 +173,16 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute ;(container as any)[key] = value } + const k = toAttributeName(container, key) + if (value === null || value === undefined || value === false) { - container.removeAttribute(key) + container.removeAttribute(k) } else if (value === true) { - container.setAttribute(key, '') + container.setAttribute(k, '') } else if (typeof value === 'string' || typeof value === 'number') { - container.setAttribute(key, value as string) + container.setAttribute(k, value as string) } else { - container.setAttribute(key, value.toString()) + container.setAttribute(k, value.toString()) } } } @@ -189,7 +200,7 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute value.current = null } } else { - container.removeAttribute(key) + container.removeAttribute(toAttributeName(container, key)) } } } @@ -248,6 +259,8 @@ const getNextChildren = ( const findInsertBefore = (node: Node | undefined): ChildNode | null => { if (!node) { return null + } else if (node.tag === HONO_PORTAL_ELEMENT) { + return findInsertBefore(node.nN) } else if (node.e) { return node.e } @@ -325,7 +338,7 @@ const applyNodeObject = (node: NodeObject, container: Container) => { const childNodes = container.childNodes let offset = findChildNodeIndex(childNodes, findInsertBefore(node.nN)) ?? - findChildNodeIndex(childNodes, next.find((n) => n.e)?.e) ?? + findChildNodeIndex(childNodes, next.find((n) => n.tag !== HONO_PORTAL_ELEMENT && n.e)?.e) ?? childNodes.length for (let i = 0, len = next.length; i < len; i++, offset++) { @@ -345,18 +358,17 @@ const applyNodeObject = (node: NodeObject, container: Container) => { applyProps(el as HTMLElement, child.props, child.pP) applyNode(child, el as HTMLElement) } - if ( - childNodes[offset] !== el && - childNodes[offset - 1] !== child.e && - child.tag !== HONO_PORTAL_ELEMENT - ) { + if (child.tag === HONO_PORTAL_ELEMENT) { + offset-- + } else if (childNodes[offset] !== el && childNodes[offset - 1] !== child.e) { container.insertBefore(el, childNodes[offset] || null) } } remove.forEach(removeNode) - callbacks.forEach(([, cb]) => cb?.()) + callbacks.forEach(([, , , , cb]) => cb?.()) // invoke useInsertionEffect callbacks + callbacks.forEach(([, cb]) => cb?.()) // invoke useLayoutEffect callbacks requestAnimationFrame(() => { - callbacks.forEach(([, , , cb]) => cb?.()) + callbacks.forEach(([, , , cb]) => cb?.()) // invoke useEffect callbacks }) } @@ -380,7 +392,7 @@ export const build = ( } const oldVChildren: Node[] = node.vC ? [...node.vC] : [] const vChildren: Node[] = [] - const vChildrenToRemove: Node[] = [] + node.vR = [] let prevNode: Node | undefined try { children.flat().forEach((c: Child) => { @@ -401,7 +413,13 @@ export const build = ( } let oldChild: Node | undefined - const i = oldVChildren.findIndex((c) => c.key === (child as Node).key) + const i = oldVChildren.findIndex( + isNodeString(child) + ? (c) => isNodeString(c) + : child.key !== undefined + ? (c) => c.key === (child as Node).key + : (c) => c.tag === (child as Node).tag + ) if (i !== -1) { oldChild = oldVChildren[i] oldVChildren.splice(i, 1) @@ -409,17 +427,13 @@ export const build = ( if (oldChild) { if (isNodeString(child)) { - if (!isNodeString(oldChild)) { - vChildrenToRemove.push(oldChild) - } else { - if (oldChild.t !== child.t) { - oldChild.t = child.t // update text content - oldChild.d = true - } - child = oldChild + if ((oldChild as NodeString).t !== child.t) { + ;(oldChild as NodeString).t = child.t // update text content + ;(oldChild as NodeString).d = true } + child = oldChild } else if (oldChild.tag !== child.tag) { - vChildrenToRemove.push(oldChild) + node.vR.push(oldChild) } else { oldChild.pP = oldChild.props oldChild.props = child.props @@ -442,8 +456,7 @@ export const build = ( } }) node.vC = vChildren - vChildrenToRemove.push(...oldVChildren) - node.vR = vChildrenToRemove + node.vR.push(...oldVChildren) } catch (e) { if (errorHandler) { const fallbackUpdateFn = () => @@ -481,10 +494,14 @@ const buildNode = (node: Child): Node | undefined => { } else if (typeof node === 'string' || typeof node === 'number') { return { t: node.toString(), d: true } as NodeString } else { + if ('vR' in node) { + node = newJSXNode({ + tag: (node as NodeObject).tag, + props: (node as NodeObject).props, + key: (node as NodeObject).key, + }) + } if (typeof (node as JSXNode).tag === 'function') { - if ((node as NodeObject)[DOM_STASH]) { - node = { ...node } as NodeObject - } ;(node as NodeObject)[DOM_STASH] = [0, []] } else { const ns = nameSpaceMap[(node as JSXNode).tag as string] diff --git a/src/jsx/dom/utils.ts b/src/jsx/dom/utils.ts index fef0c3c05..77c6b5676 100644 --- a/src/jsx/dom/utils.ts +++ b/src/jsx/dom/utils.ts @@ -1,3 +1,4 @@ +import type { Props, JSXNode } from '../base' import { DOM_INTERNAL_TAG } from '../constants' export const setInternalTagFlag = (fn: Function): Function => { @@ -5,3 +6,19 @@ export const setInternalTagFlag = (fn: Function): Function => { ;(fn as any)[DOM_INTERNAL_TAG] = true return fn } + +const JSXNodeCompatPrototype = { + type: { + get(this: { tag: string | Function }): string | Function { + return this.tag + }, + }, + ref: { + get(this: { props?: { ref: unknown } }): unknown { + return this.props?.ref + }, + }, +} + +export const newJSXNode = (obj: { tag: string | Function; props?: Props; key?: string }): JSXNode => + Object.defineProperties(obj, JSXNodeCompatPrototype) as JSXNode diff --git a/src/jsx/hooks/dom.test.tsx b/src/jsx/hooks/dom.test.tsx index 7ed3a57c8..f4a470a34 100644 --- a/src/jsx/hooks/dom.test.tsx +++ b/src/jsx/hooks/dom.test.tsx @@ -35,6 +35,7 @@ describe('Hooks', () => { }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement + global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) diff --git a/src/jsx/hooks/index.ts b/src/jsx/hooks/index.ts index ef51f2c65..2138ec5a7 100644 --- a/src/jsx/hooks/index.ts +++ b/src/jsx/hooks/index.ts @@ -15,7 +15,8 @@ export type EffectData = [ readonly unknown[] | undefined, // deps (() => void | (() => void)) | undefined, // layout effect (() => void) | undefined, // cleanup - (() => void) | undefined // effect + (() => void) | undefined, // effect + (() => void) | undefined // insertion effect ] const resolvedPromiseValueMap: WeakMap, unknown> = new WeakMap< @@ -251,7 +252,7 @@ const useEffectCommon = ( data[index] = undefined // clear this effect in order to avoid calling effect twice data[2] = effect() as (() => void) | undefined } - const data: EffectData = [deps, undefined, undefined, undefined] + const data: EffectData = [deps, undefined, undefined, undefined, undefined] data[index] = runner effectDepsArray[hookIndex] = data } @@ -262,6 +263,10 @@ export const useLayoutEffect = ( effect: () => void | (() => void), deps?: readonly unknown[] ): void => useEffectCommon(1, effect, deps) +export const useInsertionEffect = ( + effect: () => void | (() => void), + deps?: readonly unknown[] +): void => useEffectCommon(4, effect, deps) export const useCallback = unknown>( callback: T, diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index 249550d4b..b9f752519 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -758,6 +758,7 @@ describe('default export', () => { 'useViewTransition', 'useMemo', 'useLayoutEffect', + 'useInsertionEffect', 'Suspense', ].forEach((key) => { it(key, () => { diff --git a/src/jsx/index.ts b/src/jsx/index.ts index ab85197ae..0999392e1 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -16,6 +16,7 @@ import { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -51,6 +52,7 @@ export { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, createRef, forwardRef, useImperativeHandle, @@ -84,6 +86,7 @@ export default { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, createRef, forwardRef, useImperativeHandle,