Skip to content

Commit

Permalink
feat(jsx/dom): improve compatibility with React - The 2024 May Update (
Browse files Browse the repository at this point in the history
…#2756)

* fix(jsx/dom): fix rerendering of portal elements

* fix(jsx/dom): Make more reuse of existing JSX elements, similar to React

* feat(jsx/hooks): introduce useInsertionEffect hook

* feat(jsx/dom): support SVG kebab-case attributes

* fix(jsx/dom): Intrinsic elements can now also be safely Evaluated multiple times for the same element

* test: add tests

* chore: denoify
  • Loading branch information
usualoma committed May 24, 2024
1 parent ab71448 commit 0726357
Show file tree
Hide file tree
Showing 16 changed files with 552 additions and 104 deletions.
3 changes: 3 additions & 0 deletions deno_dist/jsx/dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
useViewTransition,
useMemo,
useLayoutEffect,
useInsertionEffect,
useReducer,
useId,
useDebugValue,
Expand Down Expand Up @@ -79,6 +80,7 @@ export {
useViewTransition,
useMemo,
useLayoutEffect,
useInsertionEffect,
useReducer,
useId,
useDebugValue,
Expand Down Expand Up @@ -114,6 +116,7 @@ export default {
useViewTransition,
useMemo,
useLayoutEffect,
useInsertionEffect,
useReducer,
useId,
useDebugValue,
Expand Down
27 changes: 6 additions & 21 deletions deno_dist/jsx/dom/jsx-dev-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): JSXNode => jsxDEV('', props, undefined)
75 changes: 46 additions & 29 deletions deno_dist/jsx/dom/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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())
}
}
}
Expand All @@ -189,7 +200,7 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute
value.current = null
}
} else {
container.removeAttribute(key)
container.removeAttribute(toAttributeName(container, key))
}
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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++) {
Expand All @@ -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
})
}

Expand All @@ -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) => {
Expand All @@ -401,25 +413,27 @@ 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)
}

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
Expand All @@ -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 = () =>
Expand Down Expand Up @@ -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]
Expand Down
17 changes: 17 additions & 0 deletions deno_dist/jsx/dom/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
import type { Props, JSXNode } from '../base.ts'
import { DOM_INTERNAL_TAG } from '../constants.ts'

export const setInternalTagFlag = (fn: Function): Function => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(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
9 changes: 7 additions & 2 deletions deno_dist/jsx/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Promise<unknown>, unknown> = new WeakMap<
Expand Down Expand Up @@ -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
}
Expand All @@ -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 = <T extends (...args: unknown[]) => unknown>(
callback: T,
Expand Down
3 changes: 3 additions & 0 deletions deno_dist/jsx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
useViewTransition,
useMemo,
useLayoutEffect,
useInsertionEffect,
useReducer,
useId,
useDebugValue,
Expand Down Expand Up @@ -51,6 +52,7 @@ export {
useViewTransition,
useMemo,
useLayoutEffect,
useInsertionEffect,
createRef,
forwardRef,
useImperativeHandle,
Expand Down Expand Up @@ -84,6 +86,7 @@ export default {
useViewTransition,
useMemo,
useLayoutEffect,
useInsertionEffect,
createRef,
forwardRef,
useImperativeHandle,
Expand Down
1 change: 1 addition & 0 deletions src/jsx/dom/css.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down

0 comments on commit 0726357

Please sign in to comment.