Skip to content

Optimize UI runtime hot paths and fix benchmark runner measurement#11613

Merged
ryanflorence merged 5 commits into
mainfrom
ryan/ui-perf
Jul 21, 2026
Merged

Optimize UI runtime hot paths and fix benchmark runner measurement#11613
ryanflorence merged 5 commits into
mainfrom
ryan/ui-perf

Conversation

@ryanflorence

Copy link
Copy Markdown
Member

Profile-driven performance pass over the UI runtime, plus fixes to the benchmark runner that measured it. Every operation in the packages/ui/bench suite improved 10-76% (scripting time, median of 5 runs, 4x CPU throttle); the worst outlier vs preact (large subtree teardown, 4.2x slower) is now within ~20%.

Runtime

  • Wholesale subtree discard skips per-node removeEventListener; pending handler work is aborted instead (teardownDashboard 29.3ms → 7.0ms).
  • Allocation-free component disposal when no tasks are queued; schedule-update target stored as fields instead of a fresh closure per component per render.
  • mix prop normalization fast path avoids two prop-object spreads per element per render.
  • Keyed-diff matches are number[] instead of 1000 {oldIndex} wrapper objects per table update.
  • diffVNodes dispatches on a single type check; committed-node checks drop DOM instanceof; hot walks use indexed loops instead of for-of/destructuring.
  • Cached toKebabCase / normalizeAttributeName / camelToKebab conversions.
  • Removed the dead _controller vnode field (never assigned; vestigial from the removed host-element on prop).

Benchmark runner

  • Replaced Event Timing with uniform in-page instrumentation: Event Timing never reports clicks faster than 16ms and quantizes to 8ms buckets, so fast frameworks were measured on a coarser scale than slow ones (or timed out and killed the run).
  • Single reused CDP profiling session instead of one leaked per click; correct even-count medians; filtered runs merge into saved results instead of clobbering them.

Results (remix scripting ms, before → after)

Operation Before After
teardownDashboard 29.3 7.0
update 12.5 8.5
removeRow 12.5 8.5
swapRows 12.4 9.7
selectRow 10.8 8.1
clear 16.6 12.5
replace1k 45.8 37.0
sortAsc / sortDesc ~34.9 ~28
switchToDashboard 48.9 38.7
renderDashboard 34.7 27.4
create1k 26.5 23.1
append1k 38.8 33.5

🤖 Generated with Claude Code

ryanflorence and others added 3 commits July 16, 2026 12:31
Driven by CPU and allocation profiles from the js-framework-benchmark-style
suite in packages/ui/bench. Every operation improved 10-76%; large subtree
teardown (the worst outlier at 4.2x slower than preact) is 4.3x faster.

- Skip per-node removeEventListener when a DOM subtree is discarded
  wholesale; abort pending handler work only. The <head> node keeps full
  teardown since it stays alive.
- Make component disposal allocation-free when no tasks are queued
  (shared empty array + shared aborted signal).
- Store the schedule-update target as fields behind one stable closure
  instead of allocating a closure per component per render.
- Fast-path mix prop normalization when the mix array is already flat,
  avoiding two prop-object spreads per element per render.
- Represent keyed-diff matches as number[] instead of {oldIndex} wrapper
  objects (1000 allocs per 1k-row table update).
- Dispatch diffVNodes on a single type check; curr.type === next.type is
  already established, so the paired isCommittedX/isX checks were redundant.
- Replace instanceof Element/Text with null checks in isCommitted* helpers
  and use indexed loops instead of for-of/destructuring in hot walks.
- Cache toKebabCase, normalizeAttributeName, and camelToKebab conversions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Replace Event Timing with in-page instrumentation for all measurements:
  Event Timing never reports clicks faster than 16ms and quantizes
  durations to 8ms buckets, so fast frameworks were measured on a coarser
  scale than slow ones (or timed out and killed the run entirely).
  Scripting is measured from a capture-phase listener to a post-flush
  microtask; total is captured after the frame commit (rAF + setTimeout)
  so style/layout/paint stay included.
- Reuse a single CDP session for profiling instead of leaking one per
  click, and only open it when profiling is requested. (Sessions cannot
  be detached mid-run without breaking subsequent measurements.)
- Average the two middle samples for even-length medians.
- Merge saved remix results by operation so filtered runs (-b create1k)
  no longer wipe out comparisons for unmeasured operations, and only
  display previous results for operations measured in the current run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The field was never assigned anywhere — only copied between vnodes and
conditionally aborted, so it was always undefined. Vestigial from the
removed host-element on prop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Preview Build Available

A preview build has been created for this PR. You can install it using:

pnpm install "remix-run/remix#preview/pr-11613&path:packages/remix"

This preview build will be updated automatically as you push new commits.

@ryanflorence
ryanflorence merged commit 9ed06e6 into main Jul 21, 2026
21 checks passed
@ryanflorence
ryanflorence deleted the ryan/ui-perf branch July 21, 2026 18:29
@github-actions

Copy link
Copy Markdown
Contributor

The preview branch preview/pr-11613 has been deleted now that this PR is merged/closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant