diff --git a/runtime_tests/deno-jsx/deno.precompile.json b/runtime_tests/deno-jsx/deno.precompile.json index 315f3b88c..d46d3718c 100644 --- a/runtime_tests/deno-jsx/deno.precompile.json +++ b/runtime_tests/deno-jsx/deno.precompile.json @@ -4,7 +4,8 @@ "jsxImportSource": "hono/jsx", "lib": [ "deno.ns", - "dom" + "dom", + "dom.iterable" ] }, "unstable": [ diff --git a/runtime_tests/deno-jsx/deno.react-jsx.json b/runtime_tests/deno-jsx/deno.react-jsx.json index 9e0b018a9..92e4f7722 100644 --- a/runtime_tests/deno-jsx/deno.react-jsx.json +++ b/runtime_tests/deno-jsx/deno.react-jsx.json @@ -4,7 +4,8 @@ "jsxImportSource": "hono/jsx", "lib": [ "deno.ns", - "dom" + "dom", + "dom.iterable" ] }, "unstable": [ diff --git a/src/helper/html/index.ts b/src/helper/html/index.ts index 9b881fb54..e0871c0e4 100644 --- a/src/helper/html/index.ts +++ b/src/helper/html/index.ts @@ -3,8 +3,8 @@ * html Helper for Hono. */ -import { escapeToBuffer, raw, stringBufferToString } from '../../utils/html' -import type { HtmlEscaped, HtmlEscapedString, StringBuffer } from '../../utils/html' +import { escapeToBuffer, raw, resolveCallbackSync, stringBufferToString } from '../../utils/html' +import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from '../../utils/html' export { raw } @@ -12,7 +12,7 @@ export const html = ( strings: TemplateStringsArray, ...values: unknown[] ): HtmlEscapedString | Promise => { - const buffer: StringBuffer = [''] + const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks for (let i = 0, len = strings.length - 1; i < len; i++) { buffer[0] += strings[i] @@ -48,5 +48,9 @@ export const html = ( } buffer[0] += strings[strings.length - 1] - return buffer.length === 1 ? raw(buffer[0]) : stringBufferToString(buffer) + return buffer.length === 1 + ? 'callbacks' in buffer + ? raw(resolveCallbackSync(raw(buffer[0], buffer.callbacks))) + : raw(buffer[0]) + : stringBufferToString(buffer, buffer.callbacks) } diff --git a/src/jsx/base.ts b/src/jsx/base.ts index c250a9123..f80784d59 100644 --- a/src/jsx/base.ts +++ b/src/jsx/base.ts @@ -1,13 +1,16 @@ import { raw } from '../helper/html' -import { escapeToBuffer, stringBufferToString } from '../utils/html' -import type { HtmlEscaped, HtmlEscapedString, StringBuffer } from '../utils/html' +import { escapeToBuffer, resolveCallbackSync, stringBufferToString } from '../utils/html' +import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from '../utils/html' import type { Context } from './context' import { globalContexts } from './context' +import { DOM_RENDERER } from './constants' import type { JSX as HonoJSX, IntrinsicElements as IntrinsicElementsDefined, } from './intrinsic-elements' import { normalizeIntrinsicElementKey, styleObjectForEach } from './utils' +import * as intrinsicElementTags from './intrinsic-element/components' +import { domRenderers } from './intrinsic-element/common' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Props = Record @@ -74,7 +77,7 @@ const booleanAttributes = [ 'selected', ] -const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void => { +const childrenToStringToBuffer = (children: Child[], buffer: StringBufferWithCallbacks): void => { for (let i = 0, len = children.length; i < len; i++) { const child = children[i] if (typeof child === 'string') { @@ -131,7 +134,7 @@ export class JSXNode implements HtmlEscaped { } toString(): string | Promise { - const buffer: StringBuffer = [''] + const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks this.localContexts?.forEach(([context, value]) => { context.values.push(value) }) @@ -142,10 +145,14 @@ export class JSXNode implements HtmlEscaped { context.values.pop() }) } - return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) + return buffer.length === 1 + ? 'callbacks' in buffer + ? resolveCallbackSync(raw(buffer[0], buffer.callbacks)).toString() + : buffer[0] + : stringBufferToString(buffer, buffer.callbacks) } - toStringToBuffer(buffer: StringBuffer): void { + toStringToBuffer(buffer: StringBufferWithCallbacks): void { const tag = this.tag as string const props = this.props let { children } = this @@ -214,7 +221,7 @@ export class JSXNode implements HtmlEscaped { } class JSXFunctionNode extends JSXNode { - toStringToBuffer(buffer: StringBuffer): void { + toStringToBuffer(buffer: StringBufferWithCallbacks): void { const { children } = this const res = (this.tag as Function).call(null, { @@ -242,6 +249,10 @@ class JSXFunctionNode extends JSXNode { res.toStringToBuffer(buffer) } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { buffer[0] += res + if (res.callbacks) { + buffer.callbacks ||= [] + buffer.callbacks.push(...res.callbacks) + } } else { escapeToBuffer(res, buffer) } @@ -249,7 +260,7 @@ class JSXFunctionNode extends JSXNode { } export class JSXFragmentNode extends JSXNode { - toStringToBuffer(buffer: StringBuffer): void { + toStringToBuffer(buffer: StringBufferWithCallbacks): void { childrenToStringToBuffer(this.children, buffer) } } @@ -272,13 +283,29 @@ export const jsx = ( return node } +let initDomRenderer = false export const jsxFn = ( tag: string | Function, props: Props, children: (string | number | HtmlEscapedString)[] ): JSXNode => { + if (!initDomRenderer) { + for (const k in domRenderers) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(intrinsicElementTags[k as keyof typeof intrinsicElementTags] as any)[DOM_RENDERER] = + domRenderers[k] + } + initDomRenderer = true + } + if (typeof tag === 'function') { return new JSXFunctionNode(tag, props, children) + } else if (intrinsicElementTags[tag as keyof typeof intrinsicElementTags]) { + return new JSXFunctionNode( + intrinsicElementTags[tag as keyof typeof intrinsicElementTags], + props, + children + ) } else { return new JSXNode(tag, props, children) } @@ -357,4 +384,4 @@ export const cloneElement = ( ) as T } -export const reactAPICompatVersion = '18.0.0-hono-jsx' +export const reactAPICompatVersion = '19.0.0-hono-jsx' diff --git a/src/jsx/constants.ts b/src/jsx/constants.ts index 1c5a37df7..7d1b0aae8 100644 --- a/src/jsx/constants.ts +++ b/src/jsx/constants.ts @@ -2,3 +2,4 @@ export const DOM_RENDERER = Symbol('RENDERER') export const DOM_ERROR_HANDLER = Symbol('ERROR_HANDLER') export const DOM_STASH = Symbol('STASH') export const DOM_INTERNAL_TAG = Symbol('INTERNAL') +export const PERMALINK = Symbol('PERMALINK') diff --git a/src/jsx/context.ts b/src/jsx/context.ts index 22e46f75a..882beb797 100644 --- a/src/jsx/context.ts +++ b/src/jsx/context.ts @@ -5,7 +5,7 @@ import { DOM_RENDERER } from './constants' import { createContextProviderFunction } from './dom/context' import type { FC, PropsWithChildren } from './' -export interface Context { +export interface Context extends FC> { values: T[] Provider: FC> } @@ -14,33 +14,31 @@ export const globalContexts: Context[] = [] export const createContext = (defaultValue: T): Context => { const values = [defaultValue] - const context: Context = { - values, - Provider(props): HtmlEscapedString | Promise { - values.push(props.value) - let string - try { - string = props.children - ? (Array.isArray(props.children) - ? new JSXFragmentNode('', {}, props.children) - : props.children - ).toString() - : '' - } finally { - values.pop() - } - - if (string instanceof Promise) { - return string.then((resString) => - raw(resString, (resString as HtmlEscapedString).callbacks) - ) - } else { - return raw(string) - } - }, - } + const context: Context = ((props): HtmlEscapedString | Promise => { + values.push(props.value) + let string + try { + string = props.children + ? (Array.isArray(props.children) + ? new JSXFragmentNode('', {}, props.children) + : props.children + ).toString() + : '' + } finally { + values.pop() + } + + if (string instanceof Promise) { + return string.then((resString) => raw(resString, (resString as HtmlEscapedString).callbacks)) + } else { + return raw(string) + } + }) as Context + context.values = values + context.Provider = context + // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(context.Provider as any)[DOM_RENDERER] = createContextProviderFunction(values) + ;(context as any)[DOM_RENDERER] = createContextProviderFunction(values) globalContexts.push(context as Context) diff --git a/src/jsx/dom/components.test.tsx b/src/jsx/dom/components.test.tsx index 2895a12c7..282e4a0f3 100644 --- a/src/jsx/dom/components.test.tsx +++ b/src/jsx/dom/components.test.tsx @@ -60,6 +60,40 @@ function runner( expect(root.innerHTML).toBe('

1

') }) + it('with use() update', async () => { + const counterMap: Record> = {} + const getCounter = (count: number) => (counterMap[count] ||= Promise.resolve(count + 1)) + const Content = ({ count }: { count: number }) => { + const num = use(getCounter(count)) + return ( + <> +
{num}
+ + ) + } + const Component = () => { + const [count, setCount] = useState(0) + return ( + Loading...}> + + + + ) + } + const App = + render(App, root) + expect(root.innerHTML).toBe('
Loading...
') + await Promise.resolve() + await Promise.resolve() + expect(root.innerHTML).toBe('
1
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
Loading...
') + await Promise.resolve() + await Promise.resolve() + expect(root.innerHTML).toBe('
2
') + }) + it('with use() nested', async () => { let resolve: (value: number) => void = () => {} const promise = new Promise((_resolve) => (resolve = _resolve)) @@ -131,6 +165,81 @@ function runner( await Promise.resolve() expect(root.innerHTML).toBe('

2

') }) + + it('Suspense at child', async () => { + let resolve: (value: number) => void = () => {} + const promise = new Promise((_resolve) => (resolve = _resolve)) + const Content = () => { + const num = use(promise) + return

{num}

+ } + + const Component = () => { + return ( + Loading...}> + + + ) + } + const App = () => { + const [show, setShow] = useState(false) + return ( +
+ {show && } + +
+ ) + } + render(, root) + expect(root.innerHTML).toBe('
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
Loading...
') + resolve(2) + await Promise.resolve() + await Promise.resolve() + expect(root.innerHTML).toBe('

2

') + }) + + it('Suspense at child counter', async () => { + const promiseMap: Record> = {} + const Counter = () => { + const [count, setCount] = useState(0) + const promise = (promiseMap[count] ||= Promise.resolve(count)) + const value = use(promise) + return ( + <> +

{value}

+ + + ) + } + const Component = () => { + return ( + Loading...}> + + + ) + } + const App = () => { + return ( +
+ +
+ ) + } + render(, root) + expect(root.innerHTML).toBe('
Loading...
') + await Promise.resolve() + await Promise.resolve() + expect(root.innerHTML).toBe('

0

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

1

') + }) }) describe('ErrorBoundary', () => { diff --git a/src/jsx/dom/context.test.tsx b/src/jsx/dom/context.test.tsx index c49d6b087..c0a9d3239 100644 --- a/src/jsx/dom/context.test.tsx +++ b/src/jsx/dom/context.test.tsx @@ -54,6 +54,24 @@ function runner( expect(root.innerHTML).toBe('

1

') }) + it(' as a provider ', async () => { + const Context = createContext(0) + const Content = () => { + const num = useContext(Context) + return

{num}

+ } + const Component = () => { + return ( + + + + ) + } + const App = + render(App, root) + expect(root.innerHTML).toBe('

1

') + }) + it('simple context with state', async () => { const Context = createContext(0) const Content = () => { diff --git a/src/jsx/dom/context.ts b/src/jsx/dom/context.ts index d39f52f8c..af9bd71d0 100644 --- a/src/jsx/dom/context.ts +++ b/src/jsx/dom/context.ts @@ -2,11 +2,11 @@ import type { Child } from '../base' import { DOM_ERROR_HANDLER } from '../constants' import type { Context } from '../context' import { globalContexts } from '../context' -import { Fragment } from './jsx-runtime' -import { setInternalTagFlag } from './utils' +import { newJSXNode, setInternalTagFlag } from './utils' -export const createContextProviderFunction = (values: T[]): Function => - setInternalTagFlag(({ value, children }: { value: T; children: Child[] }) => { +export const createContextProviderFunction = + (values: T[]): Function => + ({ value, children }: { value: T; children: Child[] }) => { if (!children) { return undefined } @@ -33,22 +33,20 @@ export const createContextProviderFunction = (values: T[]): Function => }), props: {}, }) - const res = Fragment(props) + const res = newJSXNode({ tag: '', props }) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(res as any)[DOM_ERROR_HANDLER] = (err: unknown) => { values.pop() throw err } return res - }) + } export const createContext = (defaultValue: T): Context => { const values = [defaultValue] - const context = { - values, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Provider: createContextProviderFunction(values) as any, - } - globalContexts.push(context) + const context: Context = createContextProviderFunction(values) as Context + context.values = values + context.Provider = context + globalContexts.push(context as Context) return context } diff --git a/src/jsx/dom/hooks/index.test.tsx b/src/jsx/dom/hooks/index.test.tsx new file mode 100644 index 000000000..aeeb9996c --- /dev/null +++ b/src/jsx/dom/hooks/index.test.tsx @@ -0,0 +1,173 @@ +/** @jsxImportSource ../../ */ +import { JSDOM } from 'jsdom' +import { render, useState } from '..' +import { useActionState, useFormStatus, useOptimistic } from '.' + +describe('Hooks', () => { + beforeAll(() => { + global.requestAnimationFrame = (cb) => setTimeout(cb) + }) + + let dom: JSDOM + let root: HTMLElement + beforeEach(() => { + dom = new JSDOM('
', { + runScripts: 'dangerously', + }) + global.document = dom.window.document + global.HTMLElement = dom.window.HTMLElement + global.SVGElement = dom.window.SVGElement + global.Text = dom.window.Text + global.FormData = dom.window.FormData + root = document.getElementById('root') as HTMLElement + }) + + describe('useActionState', () => { + it('should return initial state', () => { + const [state] = useActionState(() => {}, 'initial') + expect(state).toBe('initial') + }) + + it('should return updated state', async () => { + const action = vi.fn().mockReturnValue('updated') + + const App = () => { + const [state, formAction] = useActionState(action, 'initial') + return ( + <> +
{state}
+
+ + +
+ + ) + } + + render(, root) + expect(root.innerHTML).toBe( + '
initial
' + ) + root.querySelector('button')?.click() + await Promise.resolve() + await Promise.resolve() + expect(root.innerHTML).toBe( + '
updated
' + ) + + expect(action).toHaveBeenCalledOnce() + const [initialState, formData] = action.mock.calls[0] + expect(initialState).toBe('initial') + expect(formData).toBeInstanceOf(FormData) + expect(formData.get('name')).toBe('updated') + }) + }) + + describe('useFormStatus', () => { + it('should return initial state', () => { + const status = useFormStatus() + expect(status).toEqual({ + pending: false, + data: null, + method: null, + action: null, + }) + }) + + it('should return updated state', async () => { + let formResolve: () => void = () => {} + const formPromise = new Promise((r) => (formResolve = r)) + let status: ReturnType | undefined + const Status = () => { + status = useFormStatus() + return null + } + const App = () => { + const [, setCount] = useState(0) + return ( + <> +
{ + setCount((count) => count + 1) + return formPromise + }} + > + + + + + + ) + } + + render(, root) + expect(root.innerHTML).toBe( + '
' + ) + root.querySelector('button')?.click() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(status).toEqual({ + pending: true, + data: expect.any(FormData), + method: 'post', + action: expect.any(Function), + }) + formResolve?.() + await Promise.resolve() + await Promise.resolve() + expect(status).toEqual({ + pending: false, + data: null, + method: null, + action: null, + }) + }) + }) + + describe('useOptimistic', () => { + it('should return updated state', async () => { + let formResolve: () => void = () => {} + const formPromise = new Promise((r) => (formResolve = r)) + const App = () => { + const [count, setCount] = useState(0) + const [optimisticCount, setOptimisticCount] = useOptimistic(count, (c, n: number) => n) + return ( + <> +
{ + setOptimisticCount(count + 1) + await formPromise + setCount((count) => count + 2) + }} + > +
{optimisticCount}
+ + +
+ + ) + } + + render(, root) + expect(root.innerHTML).toBe( + '
0
' + ) + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe( + '
1
' + ) + formResolve?.() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(root.innerHTML).toBe( + '
2
' + ) + }) + }) +}) diff --git a/src/jsx/dom/hooks/index.ts b/src/jsx/dom/hooks/index.ts new file mode 100644 index 000000000..d0102a234 --- /dev/null +++ b/src/jsx/dom/hooks/index.ts @@ -0,0 +1,90 @@ +/** + * Provide hooks used only in jsx/dom + */ + +import { useContext } from '../../context' +import { createContext } from '../context' +import { useCallback, useState } from '../../hooks' +import { PERMALINK } from '../../constants' + +type FormStatus = + | { + pending: false + data: null + method: null + action: null + } + | { + pending: true + data: FormData + method: 'get' | 'post' + action: string | ((formData: FormData) => void | Promise) + } +export const FormContext = createContext({ + pending: false, + data: null, + method: null, + action: null, +}) + +const actions: Set> = new Set() +export const registerAction = (action: Promise) => { + actions.add(action) + action.finally(() => actions.delete(action)) +} + +/** + * This hook returns the current form status + * @returns FormStatus + */ +export const useFormStatus = (): FormStatus => { + return useContext(FormContext) +} + +/** + * This hook returns the current state and a function to update the state optimistically + * The current state is updated optimistically and then reverted to the original state when all actions are resolved + * @param state + * @param updateState + * @returns [T, (action: N) => void] + */ +export const useOptimistic = ( + state: T, + updateState: (currentState: T, action: N) => T +): [T, (action: N) => void] => { + const [optimisticState, setOptimisticState] = useState(state) + if (actions.size > 0) { + Promise.all(actions).finally(() => { + setOptimisticState(state) + }) + } else { + setOptimisticState(state) + } + + const cb = useCallback((newData: N) => { + setOptimisticState((currentState) => updateState(currentState, newData)) + }, []) + + return [optimisticState, cb] +} + +/** + * This hook returns the current state and a function to update the state by form action + * @param fn + * @param initialState + * @param permalink + * @returns [T, (data: FormData) => void] + */ +export const useActionState = ( + fn: Function, + initialState: T, + permalink?: string +): [T, Function] => { + const [state, setState] = useState(initialState) + const actionState = async (data: FormData) => { + setState(await fn(state, data)) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(actionState as any)[PERMALINK] = permalink + return [state, actionState] +} diff --git a/src/jsx/dom/index.test.tsx b/src/jsx/dom/index.test.tsx index 5ca83930c..48c77cda3 100644 --- a/src/jsx/dom/index.test.tsx +++ b/src/jsx/dom/index.test.tsx @@ -240,6 +240,28 @@ describe('DOM', () => { expect(root.innerHTML).toBe('') expect(ref).toHaveBeenLastCalledWith(null) }) + + it('ref cleanup function', async () => { + const cleanup = vi.fn() + const ref = vi.fn().mockReturnValue(cleanup) + const App = () => { + const [show, setShow] = useState(true) + return ( + <> + {show &&
} + + + ) + } + render(, root) + expect(root.innerHTML).toBe('
') + expect(ref).toHaveBeenLastCalledWith(expect.any(dom.window.HTMLDivElement)) + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('') + expect(ref).toBeCalledTimes(1) + expect(cleanup).toBeCalledTimes(1) + }) }) describe('defaultProps', () => { @@ -1139,7 +1161,7 @@ describe('DOM', () => { expect(createElementSpy).not.toHaveBeenCalled() }) - it('setState for unnamed function', async () => { + it('useState for unnamed function', async () => { const Input = ({ label, onInput }: { label: string; onInput: (value: string) => void }) => { return (
@@ -1181,6 +1203,40 @@ describe('DOM', () => { ) }) + it('useState for grand child function', async () => { + const GrandChild = () => { + const [count, setCount] = useState(0) + return ( + <> + {count === 0 ?

Zero

: Not Zero} + + + ) + } + const Child = () => { + return + } + const App = () => { + const [show, setShow] = useState(false) + return ( +
+ {show && } + +
+ ) + } + render(, root) + expect(root.innerHTML).toBe('
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('

Zero

') + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe( + '
Not Zero
' + ) + }) + describe('className', () => { it('should convert to class attribute for intrinsic elements', () => { const App =

Hello

@@ -1953,9 +2009,8 @@ describe('DOM', () => { ) } render(, root) - expect(root.innerHTML).toBe( - 'Document TitleSVG Title' - ) + expect(document.head.innerHTML).toBe('Document Title') + expect(root.innerHTML).toBe('SVG Title') expect(document.querySelector('title')).toBeInstanceOf(dom.window.HTMLTitleElement) expect(document.querySelector('svg title')).toBeInstanceOf(dom.window.SVGTitleElement) }) @@ -2182,6 +2237,9 @@ describe('default export', () => { 'useDeferredValue', 'startViewTransition', 'useViewTransition', + 'useActionState', + 'useFormStatus', + 'useOptimistic', 'useMemo', 'useLayoutEffect', 'Suspense', diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index fee471f35..a6ea34903 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -29,6 +29,7 @@ import { useTransition, useViewTransition, } from '../hooks' +import { useActionState, useFormStatus, useOptimistic } from './hooks' import { ErrorBoundary, Suspense } from './components' import { createContext } from './context' import { Fragment, jsx } from './jsx-runtime' @@ -94,6 +95,9 @@ export { forwardRef, useImperativeHandle, useSyncExternalStore, + useFormStatus, + useActionState, + useOptimistic, Suspense, ErrorBoundary, createContext, @@ -132,6 +136,9 @@ export default { forwardRef, useImperativeHandle, useSyncExternalStore, + useFormStatus, + useActionState, + useOptimistic, Suspense, ErrorBoundary, createContext, diff --git a/src/jsx/dom/intrinsic-element/components.test.tsx b/src/jsx/dom/intrinsic-element/components.test.tsx new file mode 100644 index 000000000..a9e29c2a7 --- /dev/null +++ b/src/jsx/dom/intrinsic-element/components.test.tsx @@ -0,0 +1,1020 @@ +/** @jsxImportSource ../../ */ +import { JSDOM, ResourceLoader } from 'jsdom' +import { useState } from '../../hooks' +import { Suspense, render } from '..' +import { clearCache, composeRef } from './components' + +describe('intrinsic element', () => { + let CustomResourceLoader: typeof ResourceLoader + beforeAll(() => { + global.requestAnimationFrame = (cb) => setTimeout(cb) + + CustomResourceLoader = class CustomResourceLoader extends ResourceLoader { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fetch(url: string) { + return url.includes('invalid') + ? Promise.reject('Invalid URL') + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Promise.resolve(Buffer.from('')) as any) + } + } + }) + + let dom: JSDOM + let root: HTMLElement + beforeEach(() => { + clearCache() + + dom = new JSDOM('
', { + runScripts: 'dangerously', + resources: new CustomResourceLoader(), + }) + global.document = dom.window.document + global.HTMLElement = dom.window.HTMLElement + global.SVGElement = dom.window.SVGElement + global.Text = dom.window.Text + global.FormData = dom.window.FormData + global.CustomEvent = dom.window.CustomEvent + root = document.getElementById('root') as HTMLElement + }) + + describe('document metadata', () => { + describe('title element', () => { + it('should be inserted into head', () => { + const App = () => { + return ( +
+ Document Title + Content +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('Document Title') + expect(root.innerHTML).toBe('
Content
') + }) + + it('should be updated', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ Document Title {count} + +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('Document Title 0') + expect(root.innerHTML).toBe('
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe('Document Title 1') + }) + + it('should be removed when unmounted', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ {count === 1 && Document Title {count}} +
{count}
+ +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe('Document Title 1') + expect(root.innerHTML).toBe('
1
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe('
2
') + }) + + it('should be inserted bottom of head if existing element is removed', async () => { + document.head.innerHTML = 'Existing Title' + + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ {count === 1 && Document Title {count}} +
{count}
+ +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('Existing Title') + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + document.head.querySelector('title')?.remove() + await Promise.resolve() + expect(document.head.innerHTML).toBe('Document Title 1') + expect(root.innerHTML).toBe('
1
') + }) + + it('should be inserted before existing title element', async () => { + document.head.innerHTML = 'Existing Title' + + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ {count === 1 && Document Title {count}} +
{count}
+ +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('Existing Title') + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + 'Document Title 1Existing Title' + ) + expect(root.innerHTML).toBe('
1
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe('Existing Title') + expect(root.innerHTML).toBe('
2
') + }) + }) + + describe('link element', () => { + it('should be inserted into head', () => { + const App = () => { + return ( +
+ + Content +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
Content
') + }) + + it('should be updated', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ + +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + }) + + it('should not do special behavior if disabled is present', () => { + const App = () => { + return ( +
+ +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe( + '
' + ) + }) + + it('should be ordered by precedence attribute', () => { + const App = () => { + return ( +
+ + + + Content +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
Content
') + }) + + it('should be de-duplicated by href attribute', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ + + {count === 1 && ( + <> + + + + )} + + {count} +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
1
') + }) + + it('should be preserved when unmounted', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ {count === 1 && } +
{count}
+ +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
1
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
2
') + }) + + it('should be blocked by blocking attribute', async () => { + const Component = () => { + return ( + Loading...
}> +
+ + Content +
+ + ) + } + const App = () => { + const [show, setShow] = useState(false) + return ( +
+ {show && } + +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe('
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
Loading...
') + await new Promise((resolve) => setTimeout(resolve)) + await Promise.resolve() + expect(root.innerHTML).toBe('
Content
') + }) + }) + + describe('style element', () => { + it('should be inserted into head', () => { + const App = () => { + return ( +
+ + Content +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
Content
') + }) + + it('should be updated', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ + +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + }) + + it('should be preserved when unmounted', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ {count === 1 && ( + + )} +
{count}
+ +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
1
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
2
') + }) + + it('should be de-duplicated by href attribute', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ + + + {count === 1 && ( + <> + + + + )} + + {count} +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
1
') + }) + + it('should be ordered by precedence attribute', () => { + const App = () => { + return ( +
+ + + + Content +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
Content
') + }) + + it('should not do special behavior if href is present', () => { + const template = ( + + + + +

World

+ + + ) + render(template, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe( + '

World

' + ) + }) + }) + + describe('meta element', () => { + it('should be inserted into head', () => { + const App = () => { + return ( +
+ + Content +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe('
Content
') + }) + + it('should be updated', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ + +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe('
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe('') + }) + + it('should not do special behavior if itemProp is present', () => { + const App = () => { + return ( +
+ + Content +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe( + '
Content
' + ) + }) + + it('should be ordered by precedence attribute', () => { + const App = () => { + return ( +
+ + + + Content +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
Content
') + }) + + it('should be de-duplicated by name attribute', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ + + {count === 1 && ( + <> + + + + )} + + {count} +
+ ) + } + render(, root) + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
1
') + }) + }) + + describe('script element', () => { + it('should be inserted into head', async () => { + const App = () => { + return ( +
+ ') + expect(root.innerHTML).toBe('
Content
') + }) + + it('should be updated', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ ') + expect(root.innerHTML).toBe('
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe('') + }) + + it('should be de-duplicated by src attribute with async=true', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ ' + ) + expect(root.innerHTML).toBe('
0
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe( + '' + ) + expect(root.innerHTML).toBe('
1
') + }) + + it('should be preserved when unmounted', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ {count === 1 && ') + expect(root.innerHTML).toBe('
1
') + root.querySelector('button')?.click() + await Promise.resolve() + expect(document.head.innerHTML).toBe('') + expect(root.innerHTML).toBe('
2
') + }) + + it('should be fired onLoad event', async () => { + const onLoad = vi.fn() + const onError = vi.fn() + const App = () => { + return ( +
+ ' + ) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve)) + expect(onLoad).toBeCalledTimes(1) + expect(onError).not.toBeCalled() + }) + + it('should be fired onError event', async () => { + const onLoad = vi.fn() + const onError = vi.fn() + const App = () => { + return ( +
+ ' + ) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve)) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve)) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve)) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve)) + expect(onLoad).not.toBeCalled() + expect(onError).toBeCalledTimes(1) + }) + + it('should be blocked by blocking attribute', async () => { + const Component = () => { + return ( + Loading...
}> +
+

World

' + ) + }) + + it('should be de-duped by href with async={true}', () => { + const template = ( + + + +

World

' + ) + }) + + it('should be omitted "blocking", "onLoad" and "onError" props', () => { + const template = ( + + + +

World

' + ) + }) + + it('should not do special behavior if async is not present', () => { + const template = ( + + + +

World

' + ) + }) + }) + + describe('style element', () => { + it('should be hoisted style tag', async () => { + const template = ( + + + + +

World

+ + + ) + expect(template.toString()).toBe( + '

World

' + ) + }) + + it('should be sorted by precedence attribute', async () => { + const template = ( + + + + + + +

World

+ + + ) + expect(template.toString()).toBe( + '

World

' + ) + }) + + it('should not be hoisted if href is not present', () => { + const template = ( + + + + +

World

+ + + ) + expect(template.toString()).toBe( + '

World

' + ) + }) + }) + }) + + describe('form element', () => { + it('should be omitted "action" prop if it is a function', () => { + const template = ( + + + +
{}} method='get'> + +
+ + + ) + expect(template.toString()).toBe( + '
' + ) + }) + + it('should be rendered permalink', () => { + const [, action] = useActionState(() => {}, {}, 'permalink') + const template = ( + + + +
+ +
+ + + ) + expect(template.toString()).toBe( + '
' + ) + }) + + it('should not do special behavior if action is a string', () => { + const template = ( + + + +
+ +
+ + + ) + expect(template.toString()).toBe( + '
' + ) + }) + + it('should not do special behavior if no action prop', () => { + const template = ( + + + +
+ +
+ + + ) + expect(template.toString()).toBe( + '
' + ) + }) + + describe('input element', () => { + it('should be rendered as is', () => { + const template = + expect(template.toString()).toBe('') + }) + + it('should be omitted "formAction" prop if it is a function', () => { + const template = ( + + + + {}} /> + + + ) + expect(template.toString()).toBe( + '' + ) + }) + + it('should be rendered permalink', () => { + const [, formAction] = useActionState(() => {}, {}, 'permalink') + const template = ( + + + + + + + ) + expect(template.toString()).toBe( + '' + ) + }) + }) + }) +}) + +export {} diff --git a/src/jsx/intrinsic-element/components.ts b/src/jsx/intrinsic-element/components.ts new file mode 100644 index 000000000..0180d5421 --- /dev/null +++ b/src/jsx/intrinsic-element/components.ts @@ -0,0 +1,172 @@ +import type { HtmlEscapedCallback, HtmlEscapedString } from '../../utils/html' +import { JSXNode } from '../base' +import type { Child, Props } from '../base' +import type { FC, PropsWithChildren } from '../types' +import { raw } from '../../helper/html' +import { dataPrecedenceAttr, deDupeKeyMap } from './common' +import { PERMALINK } from '../constants' +import { toArray } from '../children' +import type { IntrinsicElements } from '../intrinsic-elements' + +const metaTagMap: WeakMap< + object, + Record +> = new WeakMap() +const insertIntoHead: ( + tagName: string, + tag: string, + props: Props, + precedence: string | undefined +) => HtmlEscapedCallback = + (tagName, tag, props, precedence) => + ({ buffer, context }): undefined => { + if (!buffer) { + return + } + const map = metaTagMap.get(context) || {} + metaTagMap.set(context, map) + const tags = (map[tagName] ||= []) + + let duped = false + const deDupeKeys = deDupeKeyMap[tagName] + if (deDupeKeys.length > 0) { + LOOP: for (const [, tagProps] of tags) { + for (const key of deDupeKeys) { + if ((tagProps?.[key] ?? null) === props?.[key]) { + duped = true + break LOOP + } + } + } + } + + if (duped) { + buffer[0] = buffer[0].replaceAll(tag, '') + } else if (deDupeKeys.length > 0) { + tags.push([tag, props, precedence]) + } else { + tags.unshift([tag, props, precedence]) + } + + if (buffer[0].indexOf('') !== -1) { + let insertTags + if (precedence === undefined) { + insertTags = tags.map(([tag]) => tag) + } else { + const precedences: string[] = [] + insertTags = tags + .map(([tag, , precedence]) => { + let order = precedences.indexOf(precedence as string) + if (order === -1) { + precedences.push(precedence as string) + order = precedences.length - 1 + } + return [tag, order] as [string, number] + }) + .sort((a, b) => a[1] - b[1]) + .map(([tag]) => tag) + } + + insertTags.forEach((tag) => { + buffer[0] = buffer[0].replaceAll(tag, '') + }) + buffer[0] = buffer[0].replace(/(?=<\/head>)/, insertTags.join('')) + } + } + +const returnWithoutSpecialBehavior = (tag: string, children: Child, props: Props) => + raw(new JSXNode(tag, props, toArray(children ?? [])).toString()) + +const documentMetadataTag = (tag: string, children: Child, props: Props, sort: boolean) => { + if ('itemProp' in props) { + return returnWithoutSpecialBehavior(tag, children, props) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let { precedence, blocking, ...restProps } = props + precedence = sort ? precedence ?? '' : undefined + if (sort) { + restProps[dataPrecedenceAttr] = precedence + } + + const string = new JSXNode(tag, restProps, toArray(children || [])).toString() + + if (string instanceof Promise) { + return string.then((resString) => + raw(string, [ + ...((resString as HtmlEscapedString).callbacks || []), + insertIntoHead(tag, resString, restProps, precedence), + ]) + ) + } else { + return raw(string, [insertIntoHead(tag, string, restProps, precedence)]) + } +} + +export const title: FC = ({ children, ...props }) => { + return documentMetadataTag('title', children, props, false) +} +export const script: FC> = ({ + children, + ...props +}) => { + if (['src', 'async'].some((k) => !props[k])) { + return returnWithoutSpecialBehavior('script', children, props) + } + + return documentMetadataTag('script', children, props, false) +} + +export const style: FC> = ({ + children, + ...props +}) => { + if (!['href', 'precedence'].every((k) => k in props)) { + return returnWithoutSpecialBehavior('style', children, props) + } + props['data-href'] = props.href + delete props.href + return documentMetadataTag('style', children, props, true) +} +export const link: FC> = ({ children, ...props }) => { + if ( + ['onLoad', 'onError'].some((k) => k in props) || + (props.rel === 'stylesheet' && (!('precedence' in props) || 'disabled' in props)) + ) { + return returnWithoutSpecialBehavior('link', children, props) + } + return documentMetadataTag('link', children, props, 'precedence' in props) +} +export const meta: FC = ({ children, ...props }) => { + return documentMetadataTag('meta', children, props, false) +} + +const newJSXNode = (tag: string, { children, ...props }: PropsWithChildren) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new JSXNode(tag, props, toArray(children ?? []) as Child[]) as any +export const form: FC< + PropsWithChildren<{ + action?: Function | string + method?: 'get' | 'post' + }> +> = (props) => { + if (typeof props.action === 'function') { + props.action = PERMALINK in props.action ? (props.action[PERMALINK] as string) : undefined + } + return newJSXNode('form', props) +} + +const formActionableElement = ( + tag: string, + props: PropsWithChildren<{ + formAction?: Function | string + }> +) => { + if (typeof props.formAction === 'function') { + props.formAction = + PERMALINK in props.formAction ? (props.formAction[PERMALINK] as string) : undefined + } + return newJSXNode(tag, props) +} +export const input = (props: PropsWithChildren) => formActionableElement('input', props) +export const button = (props: PropsWithChildren) => formActionableElement('button', props) diff --git a/src/jsx/intrinsic-elements.ts b/src/jsx/intrinsic-elements.ts index 7f0bd12a3..5da00b0f7 100644 --- a/src/jsx/intrinsic-elements.ts +++ b/src/jsx/intrinsic-elements.ts @@ -163,6 +163,7 @@ export namespace JSX { tabindex?: number | undefined title?: string | undefined translate?: 'yes' | 'no' | undefined + itemProp?: string | undefined } type HTMLAttributeReferrerPolicy = @@ -222,6 +223,9 @@ export namespace JSX { name?: string | undefined type?: 'submit' | 'reset' | 'button' | undefined value?: string | ReadonlyArray | number | undefined + + // React 19 compatibility + formAction?: string | Function | undefined } interface CanvasHTMLAttributes extends HTMLAttributes { @@ -276,6 +280,9 @@ export namespace JSX { name?: string | undefined novalidate?: boolean | undefined target?: string | undefined + + // React 19 compatibility + action?: string | Function | undefined } interface HtmlHTMLAttributes extends HTMLAttributes { @@ -371,6 +378,9 @@ export namespace JSX { type?: HTMLInputTypeAttribute | undefined value?: string | ReadonlyArray | number | undefined width?: number | string | undefined + + // React 19 compatibility + formAction?: string | Function | undefined } interface KeygenHTMLAttributes extends HTMLAttributes { @@ -403,6 +413,15 @@ export namespace JSX { sizes?: string | undefined type?: string | undefined charSet?: string | undefined + + // React 19 compatibility + rel?: string | undefined + precedence?: string | undefined + title?: string | undefined + disabled?: boolean | undefined + onError?: ((event: Event) => void) | undefined + onLoad?: ((event: Event) => void) | undefined + blocking?: 'render' | undefined } interface MapHTMLAttributes extends HTMLAttributes { @@ -432,6 +451,9 @@ export namespace JSX { name?: string | undefined media?: string | undefined content?: string | undefined + + // React 19 compatibility + httpEquiv?: string | undefined } interface MeterHTMLAttributes extends HTMLAttributes { @@ -505,6 +527,15 @@ export namespace JSX { referrerpolicy?: HTMLAttributeReferrerPolicy | undefined src?: string | undefined type?: string | undefined + + // React 19 compatibility + crossOrigin?: CrossOrigin + fetchPriority?: string | undefined + noModule?: boolean | undefined + referrer?: HTMLAttributeReferrerPolicy | undefined + onError?: ((event: Event) => void) | undefined + onLoad?: ((event: Event) => void) | undefined + blocking?: 'render' | undefined } interface SelectHTMLAttributes extends HTMLAttributes { @@ -532,6 +563,13 @@ export namespace JSX { media?: string | undefined scoped?: boolean | undefined type?: string | undefined + + // React 19 compatibility + href?: string | undefined + precedence?: string | undefined + title?: string | undefined + disabled?: boolean | undefined + blocking?: 'render' | undefined } interface TableHTMLAttributes extends HTMLAttributes { diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index f63c5c810..f8802bccd 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -745,8 +745,8 @@ d.replaceWith(c.content) describe('use()', async () => { it('render to string', async () => { + const promise = new Promise((resolve) => setTimeout(() => resolve('Hello from use()'), 0)) const Content = () => { - const promise = new Promise((resolve) => setTimeout(() => resolve('Hello from use()'), 0)) const message = use(promise) return

{message}

} @@ -765,8 +765,8 @@ d.replaceWith(c.content) }) it('render to stream', async () => { + const promise = new Promise((resolve) => setTimeout(() => resolve('Hello from use()'), 0)) const Content = () => { - const promise = new Promise((resolve) => setTimeout(() => resolve('Hello from use()'), 0)) const message = use(promise) return

{message}

} diff --git a/src/jsx/utils.test.ts b/src/jsx/utils.test.ts index 5d78e009b..4045ba976 100644 --- a/src/jsx/utils.test.ts +++ b/src/jsx/utils.test.ts @@ -2,10 +2,16 @@ import { normalizeIntrinsicElementKey, styleObjectForEach } from './utils' describe('normalizeIntrinsicElementKey', () => { test.each` - key | expected - ${'className'} | ${'class'} - ${'htmlFor'} | ${'for'} - ${'href'} | ${'href'} + key | expected + ${'className'} | ${'class'} + ${'htmlFor'} | ${'for'} + ${'crossOrigin'} | ${'crossorigin'} + ${'httpEquiv'} | ${'http-equiv'} + ${'itemProp'} | ${'itemprop'} + ${'fetchPriority'} | ${'fetchpriority'} + ${'noModule'} | ${'nomodule'} + ${'formAction'} | ${'formaction'} + ${'href'} | ${'href'} `('should convert $key to $expected', ({ key, expected }) => { expect(normalizeIntrinsicElementKey(key)).toBe(expected) }) diff --git a/src/jsx/utils.ts b/src/jsx/utils.ts index 331a280bd..c5e875fc1 100644 --- a/src/jsx/utils.ts +++ b/src/jsx/utils.ts @@ -1,6 +1,12 @@ const normalizeElementKeyMap = new Map([ ['className', 'class'], ['htmlFor', 'for'], + ['crossOrigin', 'crossorigin'], + ['httpEquiv', 'http-equiv'], + ['itemProp', 'itemprop'], + ['fetchPriority', 'fetchpriority'], + ['noModule', 'nomodule'], + ['formAction', 'formaction'], ]) export const normalizeIntrinsicElementKey = (key: string): string => normalizeElementKeyMap.get(key) || key diff --git a/src/utils/html.ts b/src/utils/html.ts index 385060cc2..435828a40 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -35,6 +35,7 @@ export type HtmlEscapedString = string & HtmlEscaped * ] */ export type StringBuffer = (string | Promise)[] +export type StringBufferWithCallbacks = StringBuffer & { callbacks: HtmlEscapedCallback[] } export const raw = (value: unknown, callbacks?: HtmlEscapedCallback[]): HtmlEscapedString => { const escapedString = new String(value) as HtmlEscapedString @@ -49,9 +50,12 @@ export const raw = (value: unknown, callbacks?: HtmlEscapedCallback[]): HtmlEsca const escapeRe = /[&<>'"]/ -export const stringBufferToString = async (buffer: StringBuffer): Promise => { +export const stringBufferToString = async ( + buffer: StringBuffer, + callbacks: HtmlEscapedCallback[] | undefined +): Promise => { let str = '' - const callbacks: HtmlEscapedCallback[] = [] + callbacks ||= [] for (let i = buffer.length - 1; ; i--) { str += buffer[i] i-- @@ -121,6 +125,19 @@ export const escapeToBuffer = (str: string, buffer: StringBuffer): void => { buffer[0] += str.substring(lastIndex, index) } +export const resolveCallbackSync = (str: string | HtmlEscapedString): string => { + const callbacks = (str as HtmlEscapedString).callbacks as HtmlEscapedCallback[] + if (!callbacks?.length) { + return str + } + const buffer: [string] = [str] + const context = {} + + callbacks.forEach((c) => c({ phase: HtmlEscapedCallbackPhase.Stringify, buffer, context })) + + return buffer[0] +} + export const resolveCallback = async ( str: string | HtmlEscapedString, phase: (typeof HtmlEscapedCallbackPhase)[keyof typeof HtmlEscapedCallbackPhase],