Skip to content

Commit 46d7ea5

Browse files
authored
Optimize UI runtime hot paths. (#11369)
1 parent 28c6a20 commit 46d7ea5

18 files changed

Lines changed: 1257 additions & 616 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Optimize UI runtime hot paths.
2+
3+
- Fast path for plain `on()` mixins that patches host listeners in place.
4+
- Lazy direct listener closures for event listeners managed by the runtime.
5+
- Lazy mixin scope signals to avoid unnecessary AbortController work.
6+
- Faster keyed reconciliation for in-order, append-only, single-removal, and pair-swap lists.
7+
- Property-level patching for object styles during updates.
8+
- Bulk clearing for removable child lists, with an innerHTML guard.

packages/ui/bench/runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ let { values: args } = parseArgs({
5454
type: 'string',
5555
multiple: true,
5656
short: 'f',
57-
// default: ['remix', 'preact'],
57+
default: ['remix', 'preact'],
5858
},
5959
benchmark: { type: 'string', multiple: true, short: 'b' },
6060
},

packages/ui/src/runtime/component.ts

Lines changed: 94 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -227,113 +227,125 @@ export type ComponentHandle = ReturnType<typeof createComponent>
227227
* @returns Component runtime helpers used by the reconciler.
228228
*/
229229
export function createComponent<C = NoContext>(config: ComponentConfig) {
230-
let taskQueue: Task[] = []
231-
let renderCtrl: AbortController | null = null
232-
let connectedCtrl: AbortController | null = null
233-
let contextValue: C | undefined = undefined
234-
235-
function getConnectedSignal() {
236-
if (!connectedCtrl) connectedCtrl = new AbortController()
237-
return connectedCtrl.signal
238-
}
239-
240-
let getContent: null | RenderFn = null
241-
let scheduleUpdate: () => void = () => {
242-
throw new Error('scheduleUpdate not implemented')
243-
}
244-
let props = {} as ElementProps
230+
return new ComponentRuntime<C>(config)
231+
}
245232

246-
let context: Context<C> = {
247-
set: (value: C) => {
248-
contextValue = value
249-
},
250-
get: (type: Component) => config.getContext(type),
251-
}
233+
class ComponentRuntime<C = NoContext> {
234+
frame: FrameHandle
252235

253-
let handle: Handle<ElementProps, C> = {
254-
id: config.id,
255-
props,
256-
update: () =>
257-
new Promise((resolve) => {
258-
taskQueue.push((signal) => resolve(signal))
259-
scheduleUpdate()
260-
}),
261-
queueTask: (task: Task) => {
262-
taskQueue.push(task)
263-
},
264-
frame: config.frame,
265-
frames: {
266-
get top() {
267-
return config.getTopFrame?.() ?? config.frame
268-
},
269-
get(name: string) {
270-
return config.getFrameByName(name)
271-
},
272-
},
273-
context: context,
274-
get signal() {
275-
return config.signal ?? getConnectedSignal()
276-
},
236+
#config: ComponentConfig
237+
#connectedController: AbortController | undefined
238+
#contextValue: C | undefined
239+
#handle: Handle<ElementProps, C>
240+
#props = {} as ElementProps
241+
#renderController: AbortController | undefined
242+
#renderFn: RenderFn | undefined
243+
#scheduleUpdate: () => void = () => {
244+
throw new Error('scheduleUpdate not implemented')
277245
}
246+
#tasks: Task[] = []
278247

279-
function dequeueTasks(): (() => void)[] {
280-
// Only create render controller if any task expects a signal (has length >= 1)
281-
let needsSignal = taskQueue.some((task) => task.length >= 1)
282-
if (needsSignal && !renderCtrl) {
283-
renderCtrl = new AbortController()
284-
}
285-
let signal = renderCtrl?.signal
286-
return taskQueue.splice(0, taskQueue.length).map((task) => () => task(signal!))
248+
constructor(config: ComponentConfig) {
249+
this.#config = config
250+
this.frame = config.frame
251+
this.#handle = this.#createHandle()
287252
}
288253

289-
function render(props: ElementProps): [RemixNode, Array<() => void>] {
290-
if (connectedCtrl?.signal.aborted) {
254+
render = (nextProps: ElementProps): [RemixNode, Array<() => void>] => {
255+
if (this.#connectedController?.signal.aborted) {
291256
console.warn('render called after component was removed, potential application memory leak')
292257
return [null, []]
293258
}
294259

295-
// Only abort render controller if it was initialized
296-
if (renderCtrl) {
297-
renderCtrl.abort()
298-
renderCtrl = null
299-
}
260+
this.#abortRenderSignal()
261+
syncProps(this.#props, nextProps)
300262

301-
syncProps(handle.props, props)
263+
let renderFn = this.#renderFn
264+
265+
if (renderFn === undefined) {
266+
let result = this.#config.type(this.#handle)
302267

303-
let renderContent = getContent
304-
if (!renderContent) {
305-
let result = config.type(handle)
306268
if (typeof result !== 'function') {
307-
let name = config.type.name || 'Anonymous'
269+
let name = this.#config.type.name || 'Anonymous'
308270
throw new Error(`${name} must return a render function, received ${typeof result}`)
309-
} else {
310-
getContent = result
311-
renderContent = result
312271
}
313-
}
314-
if (!renderContent) {
315-
throw new Error('component render function was not initialized')
272+
273+
renderFn = result as RenderFn
274+
this.#renderFn = renderFn
316275
}
317276

318-
let node = renderContent(handle.props)
319-
return [node, dequeueTasks()]
277+
return [renderFn(this.#props), this.#dequeueTasks()]
320278
}
321279

322-
function remove(): (() => void)[] {
323-
connectedCtrl?.abort()
324-
renderCtrl?.abort()
325-
return dequeueTasks()
280+
remove = (): Array<() => void> => {
281+
this.#connectedController?.abort()
282+
this.#abortRenderSignal()
283+
return this.#dequeueTasks()
326284
}
327285

328-
function setScheduleUpdate(nextScheduleUpdate: () => void) {
329-
scheduleUpdate = nextScheduleUpdate
286+
setScheduleUpdate = (nextScheduleUpdate: () => void): void => {
287+
this.#scheduleUpdate = nextScheduleUpdate
330288
}
331289

332-
function getContextValue(): C | undefined {
333-
return contextValue
290+
getContextValue = (): C | undefined => this.#contextValue
291+
292+
#createHandle(): Handle<ElementProps, C> {
293+
let component = this
294+
let context: Context<C> = {
295+
set: (value: C) => {
296+
this.#contextValue = value
297+
},
298+
get: (type: ElementType | symbol) => this.#config.getContext(type as Component),
299+
}
300+
301+
return {
302+
id: this.#config.id,
303+
props: this.#props,
304+
update: () =>
305+
new Promise((resolve) => {
306+
this.#tasks.push((signal) => resolve(signal))
307+
this.#scheduleUpdate()
308+
}),
309+
queueTask: (task: Task) => {
310+
this.#tasks.push(task)
311+
},
312+
frame: this.#config.frame,
313+
frames: {
314+
get top() {
315+
return component.#config.getTopFrame?.() ?? component.#config.frame
316+
},
317+
get(name: string) {
318+
return component.#config.getFrameByName(name)
319+
},
320+
},
321+
context,
322+
get signal() {
323+
return component.#config.signal ?? component.#connectedSignal()
324+
},
325+
}
334326
}
335327

336-
return { render, remove, setScheduleUpdate, frame: config.frame, getContextValue }
328+
#connectedSignal(): AbortSignal {
329+
this.#connectedController ??= new AbortController()
330+
return this.#connectedController.signal
331+
}
332+
333+
#abortRenderSignal(): void {
334+
this.#renderController?.abort()
335+
this.#renderController = undefined
336+
}
337+
338+
#dequeueTasks(): Array<() => void> {
339+
let needsSignal = this.#tasks.some((task) => task.length >= 1)
340+
341+
if (needsSignal) {
342+
this.#renderController ??= new AbortController()
343+
}
344+
345+
let signal = this.#renderController?.signal
346+
let tasks = this.#tasks.splice(0, this.#tasks.length)
347+
return tasks.map((task) => () => task(signal!))
348+
}
337349
}
338350

339351
function syncProps(target: ElementProps, next: ElementProps): void {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { normalizeCssValue } from '../../style/style.ts'
2+
import { normalizeSvgAttribute } from '../svg-attributes.ts'
3+
import type { ElementProps } from '../jsx.ts'
4+
5+
const ATTRIBUTE_FALLBACK_NAMES = new Set([
6+
'width',
7+
'height',
8+
'href',
9+
'list',
10+
'form',
11+
'tabIndex',
12+
'download',
13+
'rowSpan',
14+
'colSpan',
15+
'role',
16+
'popover',
17+
])
18+
19+
export const FRAMEWORK_PROPS = new Set(['children', 'mix', 'key', 'animate', 'innerHTML', 'on'])
20+
21+
export const SELF_CLOSING_TAGS = new Set([
22+
'area',
23+
'base',
24+
'br',
25+
'col',
26+
'embed',
27+
'hr',
28+
'img',
29+
'input',
30+
'link',
31+
'meta',
32+
'param',
33+
'source',
34+
'track',
35+
'wbr',
36+
])
37+
38+
export function isChildlessElement(name: string): boolean {
39+
return SELF_CLOSING_TAGS.has(name)
40+
}
41+
42+
export function canUseProperty(
43+
element: Element,
44+
name: string,
45+
isSvg: boolean,
46+
): element is Element & Record<string, unknown> {
47+
if (isSvg) return false
48+
if (ATTRIBUTE_FALLBACK_NAMES.has(name)) return false
49+
return name in element
50+
}
51+
52+
export function normalizeAttributeName(
53+
name: string,
54+
isSvg: boolean,
55+
): { ns?: string; attr: string } {
56+
if (name.startsWith('aria-') || name.startsWith('data-')) return { attr: name }
57+
58+
if (name === 'className') return { attr: 'class' }
59+
60+
if (!isSvg) {
61+
if (name === 'htmlFor') return { attr: 'for' }
62+
if (name === 'tabIndex') return { attr: 'tabindex' }
63+
if (name === 'acceptCharset') return { attr: 'accept-charset' }
64+
if (name === 'httpEquiv') return { attr: 'http-equiv' }
65+
return { attr: name.toLowerCase() }
66+
}
67+
68+
return normalizeSvgAttribute(name)
69+
}
70+
71+
export function serializeStyleObject(style: Record<string, unknown>): string {
72+
let parts: string[] = []
73+
74+
for (let [key, value] of Object.entries(style)) {
75+
if (value == null) continue
76+
if (typeof value === 'boolean') continue
77+
if (typeof value === 'number' && !Number.isFinite(value)) continue
78+
79+
let cssKey = toKebabCase(key)
80+
let cssValue = Array.isArray(value) ? value.join(', ') : normalizeCssValue(key, value)
81+
82+
parts.push(`${cssKey}: ${cssValue};`)
83+
}
84+
85+
return parts.join(' ')
86+
}
87+
88+
export function getMergedClassName(props: ElementProps): string | undefined {
89+
let classAttr = typeof props.class === 'string' ? props.class : ''
90+
let className = typeof props.className === 'string' ? props.className : ''
91+
let merged = classAttr && className ? `${classAttr} ${className}` : classAttr || className
92+
return merged || undefined
93+
}
94+
95+
export function toKebabCase(value: string): string {
96+
return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
97+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { RemixNode } from '../jsx.ts'
2+
3+
export type EmptyChild = boolean | null | undefined
4+
export type PrimitiveChild = string | number | bigint
5+
6+
export function isEmptyChild(value: RemixNode): value is EmptyChild {
7+
return value == null || typeof value === 'boolean'
8+
}
9+
10+
export function isPrimitiveChild(value: RemixNode): value is PrimitiveChild {
11+
let type = typeof value
12+
return type === 'string' || type === 'number' || type === 'bigint'
13+
}
14+
15+
export function normalizeChildren(children: readonly RemixNode[]): RemixNode[] {
16+
for (let child of children) {
17+
if (Array.isArray(child)) {
18+
return (children as unknown[]).flat(Infinity) as RemixNode[]
19+
}
20+
}
21+
22+
return children as RemixNode[]
23+
}
24+
25+
export function packChildren(children: readonly RemixNode[]): RemixNode | undefined {
26+
if (children.length === 0) {
27+
return undefined
28+
}
29+
30+
if (children.length === 1) {
31+
let child = children[0]
32+
33+
if (child === undefined || isEmptyChild(child)) {
34+
return undefined
35+
}
36+
37+
return Array.isArray(child) ? normalizeChildren(child) : child
38+
}
39+
40+
let normalized = normalizeChildren(children)
41+
return normalized.length === 0 ? undefined : normalized
42+
}

0 commit comments

Comments
 (0)