Skip to content

feat(native): native scrollbars, portals, layout and scroll events, fonts, and a cheaper render path - #88

Merged
remorses merged 27 commits into
remorses:mainfrom
rubnogueira:perf/native-features-and-perf
Oct 11, 2026
Merged

remorses merged 27 commits into
remorses:mainfrom
rubnogueira:perf/native-features-and-perf

Conversation

@rubnogueira

Copy link
Copy Markdown
Contributor

Summary

Features a large DOM-style React app needed from GPUIX, plus the render-path costs found while profiling it. Everything is opt-in or behaviour-preserving apart from the notes under "Behaviour changes".

New

  • createPortal(children, target) mounts children into an element that is already mounted, kept in key order. See "Portals" in the README.
  • scrollbarColor / scrollbarWidth paint a native thumb on overflow: scroll containers and on <virtual-list>. The thumb is drawn in the frame the scroll happens: no JS in the loop, no extra frame, no pointer input.
    • A list's thumb counts unmeasured rows as the average measured row, not estimatedItemHeight.
    • padding on a <virtual-list> now insets its rows on all four sides (it ignored left and right before), and the thumb stays at the list's own edge, past the padding.
  • onLayout on <div> and <text> reports the border box after first paint and on each size change.
  • onScrollChange reports the offset of an overflow: scroll element after any scroll (wheel, scrollbar, scrollTo, scrollIntoView).
  • registerFont(bytes) loads a TrueType/OpenType font that fontFamily can name.
  • getElementsAt(x, y) lists the elements under a window point, topmost first, like elementsFromPoint.
  • collectGarbageWhenIdle() runs one full GC after the app has stopped committing. It watches the new appliedBatchCount() and CPU.
  • Styles: aspectRatio, borderStyle: "dashed", boxShadow arrays and inset, alignItems: stretch | baseline, justifyContent: space-evenly.
  • @gpuix/native/host exports sameMutationValue and EVENT_TYPE_BY_PROP for adapters that diff the same payloads.

Fixed

  • Leaving the window now ends hover (the hover style, cursor and tooltips stayed until the pointer came back). The test renderer gains simulateMouseExit.
  • A style change on an element React has hidden (Suspense, Activity) no longer makes it visible again.

Performance

  • Re-renders no longer resend styles and custom props that did not change. The custom prop diff is linear instead of quadratic, and the event handler diff walks the props an element has.
  • applyBatch repaints only when the batch changed something a frame would draw.
  • Host elements no longer carry a hidden canvas child to record bounds, so a frame over the same tree is about 15% cheaper.
  • Focus sync visits only focusable elements: an idle frame of a tree with thousands of elements costs about a third less CPU.
  • The find-in-page query is compiled once per keystroke instead of per text block.
  • root.render() keeps one context value, so useGpuix() consumers stop re-rendering on every call. In the chat example a find-bar keystroke went from 305 row renders and about 9 ms to 0.2 ms.
  • New perf budgets run against a production React build, with a chat profiler and a re-render benchmark under examples/.

Behaviour changes to review

  • getElementBounds(), automation locators and text selection regions use the element's border box. They used the padding box, which was narrower by the border width.
  • @gpuix/react moves to react-reconciler@^0.33.0 and peers on react@^19.2.0 (the old ^18 half of the range was never true).
  • visibility: hidden elements keep a bounds child, because gpui paints nothing for them.

Zed fork

Depends on remorses/zed#10, five commits on gpuix: forget failed font lookups when fonts are added, end hover at the window edge, ListState::averaged_scroll_extent, rows laid out inside left and right list padding, and a doc wording fix. That PR must merge first. The submodule pointer here references its head (c2e9f88) and has to be updated if it is squashed or rebased.

Test plan

  • packages/react: 536/536 against an addon built from this HEAD
  • bun run build:native and bun run build:react
  • Pixel tests read logical pixels, so they pass on 1x and 2x displays
  • Checked in a large React app running on GPUIX through a DOM compatibility layer: native thumbs at the right edge, padding preserved
  • cargo test for the native crate and gpui's list tests were not re-run on the final commits

19 changesets: native patch/minor, react patch/minor.

Authored with Cursor (Claude Sonnet 5.5).

…ers stop re-rendering

Each render() call wrapped the tree in a Provider whose value was a new
{ renderer } object, so every call was a context change. In the chat example
that re-rendered ChatApp, whose inline onRetry closure then busted the memo on
Transcript: 305 SafeMdxContent renders and about 9 ms per find-bar keystroke.

The example also keeps onRetry stable now, so typing in the composer no longer
re-renders every row.
A padded box around a list is better served by padding on the list itself,
with the thumb at the list's own edge. The list now takes padding on all
four sides.
…ides

The list ignored left and right padding, so rows spanned its full width. Rows now sit inside all four sides, and a scrollbarColor thumb stays at the list's own edge, past the padding. Moves the zed submodule to the commit that lays rows out inside horizontal padding.
@remorses
remorses marked this pull request as ready for review October 11, 2026 13:34
@remorses
remorses merged commit f1e7a96 into remorses:main Oct 11, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T13:49:23.420259Z 447ade9 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

let none = match style.and_then(|style| style.pointer_events.as_deref()) {
Some("none") => true,
Some("auto") => false,
_ => none,

P1 Badge Stop inheriting pointerEvents in hit testing

For a pointerEvents: "none" wrapper whose child leaves pointerEvents unset, RetainedTree::collect_hits carries none into every descendant here, so getElementsAt drops the child even though GPUIX intentionally makes this property non-inherited and the child keeps its own hitbox. This makes the query disagree with actual clicks for translating or overlay wrappers; suppress only the element that declares none rather than passing that state to children.

AGENTS.md reference: AGENTS.md:L782-L784


if !clipped && !none && own_box.is_some_and(|bounds| inside(&bounds, true, true)) {
hits.push(id);

P1 Badge Exclude primitive text nodes from element hit results

When the point is over rendered text, this unconditional push includes each content-bearing primitive host text node before its JSX parent. React splits <text>Hello {name}!</text> into several such nodes, but their IDs have no PublicInstance/ref or event handler and DOM elementsFromPoint would return the parent element, so the documented [topmost] result becomes an unusable internal ID on ordinary labels; skip primitive text instances or coalesce them to their parent.

AGENTS.md reference: AGENTS.md:L230-L232


let clips = |axis: Option<&str>| matches!(axis, Some("hidden" | "scroll"));
let clip_x = clips(style.overflow_x.as_deref().or(style.overflow.as_deref()));
let clip_y = clips(style.overflow_y.as_deref().or(style.overflow.as_deref()));

P1 Badge Clip virtual-list rows to the list viewport

For a <virtual-list> with a partially visible row taller than its viewport (or an overdraw row), this branch never marks the point clipped: build_virtual_list uses gpui::list() without storing overflow: "scroll" in StyleDesc, and it does not record a box for the list ID. The row's full painted border box can therefore be returned for coordinates outside the list, even though the new getElementsAt contract says virtual-list and scroll content is clipped; record and use the list viewport as an implicit scroll clip.

AGENTS.md reference: AGENTS.md:L7-L11


let face = ttf_parser::Face::parse(bytes, 0)

P2 Badge Validate every face in a font collection

When data is a TTC/OTC collection, check_font validates only face index 0 even though registerFont advertises loading every font in a collection and passes the entire buffer to add_fonts. A later face without the required names or m glyph therefore bypasses the guard, so it can be silently unavailable or reach the GPUI abort that this check is intended to prevent; enumerate and validate every face before registering the bytes.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

2 participants