Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(jsx): Tidyup the types to be exported #2151

Merged
merged 4 commits into from
Feb 5, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
331 changes: 331 additions & 0 deletions deno_dist/jsx/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
import { raw } from '../helper/html/index.ts'
import { escapeToBuffer, stringBufferToString } from '../utils/html.ts'
import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html.ts'
import type { Context } from './context.ts'
import { globalContexts } from './context.ts'
import type { IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements.ts'
import { normalizeIntrinsicElementProps } from './utils.ts'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Props = Record<string, any>
export type FC<T = Props> = (props: T) => HtmlEscapedString | Promise<HtmlEscapedString>

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
type Element = HtmlEscapedString | Promise<HtmlEscapedString>
interface ElementChildrenAttribute {
children: Child
}
interface IntrinsicElements extends IntrinsicElementsDefined {
[tagName: string]: Props
}
}
}

const emptyTags = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr',
]
const booleanAttributes = [
'allowfullscreen',
'async',
'autofocus',
'autoplay',
'checked',
'controls',
'default',
'defer',
'disabled',
'formnovalidate',
'hidden',
'inert',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'nomodule',
'novalidate',
'open',
'playsinline',
'readonly',
'required',
'reversed',
'selected',
]

const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void => {
for (let i = 0, len = children.length; i < len; i++) {
const child = children[i]
if (typeof child === 'string') {
escapeToBuffer(child, buffer)
} else if (typeof child === 'boolean' || child === null || child === undefined) {
continue
} else if (child instanceof JSXNode) {
child.toStringToBuffer(buffer)
} else if (
typeof child === 'number' ||
(child as unknown as { isEscaped: boolean }).isEscaped
) {
;(buffer[0] as string) += child
} else if (child instanceof Promise) {
buffer.unshift('', child)
} else {
// `child` type is `Child[]`, so stringify recursively
childrenToStringToBuffer(child, buffer)
}
}
}

type LocalContexts = [Context<unknown>, unknown][]
export type Child = string | Promise<string> | number | JSXNode | Child[]
export class JSXNode implements HtmlEscaped {
tag: string | Function
props: Props
key?: string
children: Child[]
isEscaped: true = true as const
localContexts?: LocalContexts
constructor(tag: string | Function, props: Props, children: Child[]) {
this.tag = tag
this.props = props
this.children = children
}

toString(): string | Promise<string> {
const buffer: StringBuffer = ['']
this.localContexts?.forEach(([context, value]) => {
context.values.push(value)
})
try {
this.toStringToBuffer(buffer)
} finally {
this.localContexts?.forEach(([context]) => {
context.values.pop()
})
}
return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer)
}

toStringToBuffer(buffer: StringBuffer): void {
const tag = this.tag as string
const props = this.props
let { children } = this

buffer[0] += `<${tag}`

const propsKeys = Object.keys(props || {})

for (let i = 0, len = propsKeys.length; i < len; i++) {
const key = propsKeys[i]
const v = props[key]
// object to style strings
if (key === 'style' && typeof v === 'object') {
const styles = Object.keys(v)
.map((k) => {
const property = k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
return `${property}:${v[k]}`
})
.join(';')
buffer[0] += ` style="${styles}"`
} else if (typeof v === 'string') {
buffer[0] += ` ${key}="`
escapeToBuffer(v, buffer)
buffer[0] += '"'
} else if (v === null || v === undefined) {
// Do nothing
} else if (typeof v === 'number' || (v as HtmlEscaped).isEscaped) {
buffer[0] += ` ${key}="${v}"`
} else if (typeof v === 'boolean' && booleanAttributes.includes(key)) {
if (v) {
buffer[0] += ` ${key}=""`
}
} else if (key === 'dangerouslySetInnerHTML') {
if (children.length > 0) {
throw 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.'
}

children = [raw(v.__html)]
} else if (v instanceof Promise) {
buffer[0] += ` ${key}="`
buffer.unshift('"', v)
} else {
buffer[0] += ` ${key}="`
escapeToBuffer(v.toString(), buffer)
buffer[0] += '"'
}
}

if (emptyTags.includes(tag as string)) {
buffer[0] += '/>'
return
}

buffer[0] += '>'

childrenToStringToBuffer(children, buffer)

buffer[0] += `</${tag}>`
}
}

class JSXFunctionNode extends JSXNode {
toStringToBuffer(buffer: StringBuffer): void {
const { children } = this

const res = (this.tag as Function).call(null, {
...this.props,
children: children.length <= 1 ? children[0] : children,
})

if (res instanceof Promise) {
if (globalContexts.length === 0) {
buffer.unshift('', res)
} else {
// save current contexts for resuming
const currentContexts: LocalContexts = globalContexts.map((c) => [c, c.values.at(-1)])
buffer.unshift(
'',
res.then((childRes) => {
if (childRes instanceof JSXNode) {
childRes.localContexts = currentContexts
}
return childRes
})
)
}
} else if (res instanceof JSXNode) {
res.toStringToBuffer(buffer)
} else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) {
buffer[0] += res
} else {
escapeToBuffer(res, buffer)
}
}
}

export class JSXFragmentNode extends JSXNode {
toStringToBuffer(buffer: StringBuffer): void {
childrenToStringToBuffer(this.children, buffer)
}
}

export const jsx = (
tag: string | Function,
props: Props,
...children: (string | HtmlEscapedString)[]
): JSXNode => {
let key
if (props) {
key = props?.key
delete props['key']
}
const node = jsxFn(tag, props, children)
node.key = key
return node
}

export const jsxFn = (
tag: string | Function,
props: Props,
children: (string | HtmlEscapedString)[]
): JSXNode => {
if (typeof tag === 'function') {
return new JSXFunctionNode(tag, props, children)
} else {
normalizeIntrinsicElementProps(props)
return new JSXNode(tag, props, children)
}
}

const shallowEqual = (a: Props, b: Props): boolean => {
if (a === b) {
return true
}

const aKeys = Object.keys(a).sort()
const bKeys = Object.keys(b).sort()
if (aKeys.length !== bKeys.length) {
return false
}

for (let i = 0, len = aKeys.length; i < len; i++) {
if (
aKeys[i] === 'children' &&
bKeys[i] === 'children' &&
!a.children?.length &&
!b.children?.length
) {
continue
} else if (a[aKeys[i]] !== b[aKeys[i]]) {
return false
}
}

return true
}

export const memo = <T>(
component: FC<T>,
propsAreEqual: (prevProps: Readonly<T>, nextProps: Readonly<T>) => boolean = shallowEqual
): FC<T> => {
let computed: HtmlEscapedString | Promise<HtmlEscapedString> | undefined = undefined
let prevProps: T | undefined = undefined
return ((props: T & { children?: Child }): HtmlEscapedString | Promise<HtmlEscapedString> => {
if (prevProps && !propsAreEqual(prevProps, props)) {
computed = undefined
}
prevProps = props
return (computed ||= component(props))
}) as FC<T>
}

export const Fragment = ({
children,
}: {
key?: string
children?: Child | HtmlEscapedString
}): HtmlEscapedString => {
return new JSXFragmentNode(
'',
{},
Array.isArray(children) ? children : children ? [children] : []
) as never
}

export const isValidElement = (element: unknown): element is JSXNode => {
return !!(
element &&
typeof element === 'object' &&
'tag' in element &&
'props' in element &&
'children' in element
)
}

export const cloneElement = <T extends JSXNode | JSX.Element>(
element: T,
props: Partial<Props>,
...children: Child[]
): T => {
return jsxFn(
(element as JSXNode).tag,
{ ...(element as JSXNode).props, ...props },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children.length ? children : ((element as JSXNode).children as any) || []
) as T
}
14 changes: 8 additions & 6 deletions deno_dist/jsx/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html.ts'
import { DOM_RENDERER } from './constants.ts'
import { ErrorBoundary as ErrorBoundaryDomRenderer } from './dom/components.ts'
import type { HasRenderToDom } from './dom/render.ts'
import type { FC, Child } from './index.ts'
import type { FC, PropsWithChildren, Child } from './index.ts'

let errorBoundaryCounter = 0

Expand All @@ -29,11 +29,13 @@ export type FallbackRender = (error: Error) => Child
* `ErrorBoundary` is an experimental feature.
* The API might be changed.
*/
export const ErrorBoundary: FC<{
fallback?: Child
fallbackRender?: FallbackRender
onError?: ErrorHandler
}> = async ({ children, fallback, fallbackRender, onError }) => {
export const ErrorBoundary: FC<
PropsWithChildren<{
fallback?: Child
fallbackRender?: FallbackRender
onError?: ErrorHandler
}>
> = async ({ children, fallback, fallbackRender, onError }) => {
if (!children) {
return raw('')
}
Expand Down
6 changes: 3 additions & 3 deletions deno_dist/jsx/context.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { raw } from '../helper/html/index.ts'
import type { HtmlEscapedString } from '../utils/html.ts'
import { JSXFragmentNode } from './base.ts'
import { DOM_RENDERER } from './constants.ts'
import { createContextProviderFunction } from './dom/context.ts'
import { JSXFragmentNode } from './index.ts'
import type { FC } from './index.ts'
import type { FC, PropsWithChildren } from './index.ts'

export interface Context<T> {
values: T[]
Provider: FC<{ value: T }>
Provider: FC<PropsWithChildren<{ value: T }>>
}

export const globalContexts: Context<unknown>[] = []
Expand Down