From cd767445129bf5c32c9e5ef043704b91d9f0f908 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:19:32 +0100 Subject: [PATCH 01/27] perf(react): skip unchanged style and custom prop updates --- .changeset/skip-unchanged-style-updates.md | 10 ++ packages/native/js/mutations.ts | 43 +++++ .../src/__tests__/mutation-lifecycle.test.tsx | 49 ++++++ .../src/__tests__/style-updates.test.tsx | 157 ++++++++++++++++++ packages/react/src/reconciler/host-config.ts | 96 ++++++++--- 5 files changed, 333 insertions(+), 22 deletions(-) create mode 100644 .changeset/skip-unchanged-style-updates.md create mode 100644 packages/react/src/__tests__/style-updates.test.tsx diff --git a/.changeset/skip-unchanged-style-updates.md b/.changeset/skip-unchanged-style-updates.md new file mode 100644 index 000000000..2e5ae6e1d --- /dev/null +++ b/.changeset/skip-unchanged-style-updates.md @@ -0,0 +1,10 @@ +--- +'@gpuix/react': patch +'@gpuix/native': patch +--- + +A re-render no longer resends styles and custom props that did not change. React calls `commitUpdate` for every element a parent re-rendered, and the reconciler used to serialize and send each one's `style` every time, which cost a JSON round trip and a style intern per element per commit. It now compares values, so `style={{ ... }}` rebuilt with the same contents sends nothing. The custom prop diff is also linear instead of quadratic in the number of props. + +A style change on an element React has hidden (Suspense, Activity) no longer makes it visible again: it keeps `visibility: hidden` until React unhides it. + +`@gpuix/native/host` exports `sameMutationValue(left, right)` for adapters that diff the same payloads. diff --git a/packages/native/js/mutations.ts b/packages/native/js/mutations.ts index 8168ef0bd..857eebff8 100644 --- a/packages/native/js/mutations.ts +++ b/packages/native/js/mutations.ts @@ -133,6 +133,49 @@ export function isReservedProp(name: string): boolean { return RESERVED_PROPS.has(name) || EVENT_PROP_NAMES.has(name) } +/** + * Structural equality for the JSON values a mutation carries: styles and + * custom props. A key whose value is `undefined` counts as absent, because + * `JSON.stringify` drops it, so `{ color: undefined }` and `{}` reach Rust as + * the same payload and must compare equal here. + * + * Only plain objects, arrays and primitives are compared. A function or class + * instance is equal to itself and nothing else. + */ +export function sameMutationValue(left: unknown, right: unknown): boolean { + if (left === right) return true + // NaN serializes to null on both sides, and -0 to 0, so neither is a change. + if (typeof left === "number" && typeof right === "number") return left !== left && right !== right + if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) { + return false + } + if (Array.isArray(left)) { + if (!Array.isArray(right) || left.length !== right.length) return false + for (let index = 0; index < left.length; index++) { + if (!sameMutationValue(left[index], right[index])) return false + } + return true + } + if (Array.isArray(right)) return false + const leftProto = Object.getPrototypeOf(left) + if (leftProto !== Object.prototype && leftProto !== null) return false + if (Object.getPrototypeOf(right) !== leftProto) return false + const leftRecord = left as Record + const rightRecord = right as Record + let defined = 0 + for (const key in leftRecord) { + const value = leftRecord[key] + if (value === undefined) continue + defined++ + if (!sameMutationValue(value, rightRecord[key])) return false + } + let rightDefined = 0 + for (const key in rightRecord) { + if (rightRecord[key] !== undefined) rightDefined++ + } + return defined === rightDefined +} + export function serializeCustomProp( value: object | string | number | boolean | null | undefined ): string | object | number | boolean | null { diff --git a/packages/react/src/__tests__/mutation-lifecycle.test.tsx b/packages/react/src/__tests__/mutation-lifecycle.test.tsx index 76eedf5c7..99e55aa82 100644 --- a/packages/react/src/__tests__/mutation-lifecycle.test.tsx +++ b/packages/react/src/__tests__/mutation-lifecycle.test.tsx @@ -71,6 +71,55 @@ describe("host config hideInstance", () => { hostConfig.unhideInstance(instance, props) expect(renderer.styles.at(-1)).toEqual(props.style) }) + + function hiddenFixture(props: Props) { + const renderer = recordingRenderer() + const container: Container = { + nativeRenderer: {}, + renderer, + ids: { nextElementId: 0 }, + eventHandlers: new Map(), + windowKeyEventHandlers: {}, + windowKeyEventId: 0, + windowSelectionEventId: 0, + } + const instance = hostConfig.createInstance( + "div", + props, + container, + null as unknown as HostContext + ) + return { renderer, instance } + } + + it("keeps an element hidden when its style changes", () => { + const before: Props = { style: { width: 80, hover: { backgroundColor: "#a6e3a1" } } } + const after: Props = { style: { width: 120, hover: { backgroundColor: "#a6e3a1" } } } + const { renderer, instance } = hiddenFixture(before) + + hostConfig.hideInstance(instance) + hostConfig.commitUpdate(instance, "div", before, after, null) + expect(renderer.styles.at(-1)).toEqual({ width: 120, visibility: "hidden" }) + + hostConfig.unhideInstance(instance, after) + expect(renderer.styles.at(-1)).toEqual(after.style) + }) + + it("does not resend an unchanged style while hidden", () => { + const { renderer, instance } = hiddenFixture({ style: { width: 80 } }) + hostConfig.hideInstance(instance) + const sent = renderer.styles.length + + hostConfig.commitUpdate( + instance, + "div", + { style: { width: 80 } }, + { style: { width: 80 } }, + null + ) + + expect(renderer.styles).toHaveLength(sent) + }) }) describeNative("mutation lifecycle", () => { diff --git a/packages/react/src/__tests__/style-updates.test.tsx b/packages/react/src/__tests__/style-updates.test.tsx new file mode 100644 index 000000000..98a14edbe --- /dev/null +++ b/packages/react/src/__tests__/style-updates.test.tsx @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +type Op = [string, ...unknown[]] + +/** Every op that crossed into Rust, per `applyBatch` call. */ +function recordBatches(renderer: { applyBatch(json: string): unknown }) { + const batches: Op[][] = [] + const original = renderer.applyBatch.bind(renderer) + renderer.applyBatch = (json: string) => { + batches.push(JSON.parse(json) as Op[]) + return original(json) + } + return { + batches, + ops: (name: string) => batches.flat().filter((op) => op[0] === name), + clear: () => { + batches.length = 0 + }, + } +} + +describeNative("style and custom prop updates", () => { + it("sends nothing when a re-render rebuilds an equal style object", () => { + const { render, renderer, unmount } = createTestRoot() + const view = (color: string) => ( +
+ hello +
+ ) + try { + render(view("#222")) + const sent = recordBatches(renderer) + + render(view("#222")) + render(view("#222")) + + expect(sent.batches).toEqual([]) + } finally { + unmount() + } + }) + + it("sends only the element whose style changed", () => { + const { render, renderer, unmount } = createTestRoot() + const view = (hovered: string) => ( +
+
+
+
+ ) + try { + render(view("#222")) + const sent = recordBatches(renderer) + + render(view("#333")) + + const styles = sent.ops("setStyle") + expect(styles).toHaveLength(1) + expect(styles[0]![2]).toEqual({ height: 10, backgroundColor: "#333" }) + } finally { + unmount() + } + }) + + it("reaches a changed nested state style", () => { + const { render, renderer, unmount } = createTestRoot() + const view = (hover: string) => ( +
+ ) + try { + render(view("#222")) + const sent = recordBatches(renderer) + + render(view("#444")) + + expect(sent.ops("setStyle")).toHaveLength(1) + } finally { + unmount() + } + }) + + it("resets a style that was removed", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(
) + const sent = recordBatches(renderer) + + render(
) + + const styles = sent.ops("setStyle") + expect(styles).toHaveLength(1) + expect(styles[0]![2]).toEqual({}) + } finally { + unmount() + } + }) + + it("treats an undefined style value as absent", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(
) + const sent = recordBatches(renderer) + + render(
) + + expect(sent.batches).toEqual([]) + } finally { + unmount() + } + }) + + it("sends a custom prop once per change, not once per render", () => { + const { render, renderer, unmount } = createTestRoot() + const view = (label: string, handler: () => void) => ( +
+ ) + try { + render(view("one", () => {})) + const sent = recordBatches(renderer) + + // A new handler and a new but equal `highlight` object on every render. + render(view("one", () => {})) + expect(sent.ops("setCustomProp")).toEqual([]) + + render(view("two", () => {})) + const props = sent.ops("setCustomProp") + expect(props).toHaveLength(1) + expect(props[0]!.slice(2)).toEqual(["aria-label", "two"]) + } finally { + unmount() + } + }) + + it("clears a custom prop that is no longer passed", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(
) + const sent = recordBatches(renderer) + + render(
) + + const props = sent.ops("setCustomProp") + expect(props).toHaveLength(1) + expect(props[0]!.slice(2)).toEqual(["aria-label", null]) + } finally { + unmount() + } + }) +}) diff --git a/packages/react/src/reconciler/host-config.ts b/packages/react/src/reconciler/host-config.ts index c13318d79..8c875c430 100644 --- a/packages/react/src/reconciler/host-config.ts +++ b/packages/react/src/reconciler/host-config.ts @@ -10,6 +10,7 @@ import { BUILT_IN_TYPES, EVENT_PROPS, isReservedProp, + sameMutationValue, serializeCustomProp, UNIVERSAL_PROPS, } from "@gpuix/native/host" @@ -40,6 +41,12 @@ interface HostNodeState { container: Container initialChildren: HostNode[] mounted: boolean + /** + * React hid this element (Suspense, Activity). Its native style carries + * `visibility: hidden`, so a style update must keep that rather than + * overwrite it with the visible one. + */ + hidden: boolean } const hostNodeStates = new WeakMap() @@ -123,12 +130,36 @@ function diffEventListeners( // ── Style helper ───────────────────────────────────────────────────── +const NO_STYLE = {} + function sendStyle(renderer: MutationRenderer, id: number, props: Props): void { const style = props.style if (style == null || Object.keys(style).length === 0) return renderer.setStyle(id, style) } +/** + * Whether two `style` props reach Rust as the same payload. An absent style and + * an empty one are the same, and so are two objects holding the same values: + * `style={{ ... }}` is a new object on every render, so reference equality + * alone would resend every styled element on every commit. + * + * Like React DOM, this does not see a style object mutated in place and passed + * again. Pass a new object when a style changes. + */ +function sameStyle(left: Props["style"], right: Props["style"]): boolean { + return left === right || sameMutationValue(left ?? NO_STYLE, right ?? NO_STYLE) +} + +/** The style a hidden element keeps: its own, minus the states, plus `visibility: hidden`. */ +function hiddenStyle(style: Props["style"]) { + // Hover and active go, because a hidden element must stay hidden. A hover + // style that sets `visibility` would otherwise paint an element React + // asked to hide. + const { hover: _hover, active: _active, ...base } = style ?? {} + return { ...base, visibility: "hidden" } +} + // ── Custom prop forwarding ─────────────────────────────────────────── // Props that are handled by the reconciler directly (not forwarded as custom props). @@ -147,7 +178,13 @@ function syncCustomProps( } } -/** Diff and send changed custom props to Rust. */ +/** + * Diff and send changed custom props to Rust. + * + * Compared by value after serialization: a handler or an inline object is new on + * every render, and a function serializes to `null` either way, so resending it + * would only make Rust parse and discard the same value again. + */ function diffCustomProps( renderer: MutationRenderer, id: number, @@ -156,24 +193,32 @@ function diffCustomProps( newProps: Props ): void { const builtIn = BUILT_IN_TYPES.has(type) - const oldEntries = Object.entries(oldProps) - const newKeys = Object.keys(newProps) + const previous = oldProps as Record + const next = newProps as Record // Updated or added props - for (const [key, value] of Object.entries(newProps)) { + for (const key in next) { if (isReservedProp(key)) continue if (builtIn && !UNIVERSAL_PROPS.has(key)) continue - const oldValue = oldEntries.find(([oldKey]) => oldKey === key)?.[1] - if (oldValue !== value) { - renderer.setCustomProp(id, key, serializeCustomProp(value)) + const value = next[key] + const oldValue = previous[key] + if (value === oldValue) continue + const serialized = serializeCustomProp(value as Parameters[0]) + if ( + key in previous && + sameMutationValue( + serializeCustomProp(oldValue as Parameters[0]), + serialized + ) + ) { + continue } + renderer.setCustomProp(id, key, serialized) } // Removed props - for (const key of Object.keys(oldProps)) { + for (const key in previous) { if (isReservedProp(key)) continue if (builtIn && !UNIVERSAL_PROPS.has(key)) continue - if (!newKeys.includes(key)) { - renderer.setCustomProp(id, key, null) - } + if (!(key in next)) renderer.setCustomProp(id, key, null) } } @@ -189,7 +234,8 @@ function materialize(node: HostNode): HostNodeState { const renderer = state.container.renderer if ("type" in node) { renderer.createElement(node.id, node.type) - sendStyle(renderer, node.id, node.props) + if (state.hidden) renderer.setStyle(node.id, hiddenStyle(node.props.style)) + else sendStyle(renderer, node.id, node.props) syncEventListeners(state.container, node.id, node.props) syncCustomProps(renderer, node.id, node.type, node.props) } else { @@ -231,6 +277,7 @@ export const hostConfig = { container: rootContainerInstance, initialChildren: [], mounted: false, + hidden: false, }) return instance }, @@ -318,6 +365,7 @@ export const hostConfig = { container: rootContainerInstance, initialChildren: [], mounted: false, + hidden: false, }) return instance }, @@ -354,10 +402,17 @@ export const hostConfig = { newProps: Props, _internalInstanceHandle: unknown ): void { - const container = containerFor(instance) - // Always resend style — per-element JSON is small, and this avoids - // bugs from same-reference mutations or style removal. - container.renderer.setStyle(instance.id, newProps.style ?? {}) + const state = stateFor(instance) + const { container } = state + // `commitUpdate` runs for every host element whose props object is new, + // which is every element a parent re-rendered. Most of those did not change + // their style, so send it only when it did. + if (!sameStyle(oldProps.style, newProps.style)) { + container.renderer.setStyle( + instance.id, + state.hidden ? hiddenStyle(newProps.style) : (newProps.style ?? {}) + ) + } diffEventListeners(container, instance.id, oldProps, newProps) // Custom prop diff (for non-div/text elements) diffCustomProps(container.renderer, instance.id, instance.type, oldProps, newProps) @@ -386,15 +441,12 @@ export const hostConfig = { // Keep the element's own style. `visibility: hidden` skips the paint and // keeps the layout box, so replacing the whole style here would collapse // the box and lose every other style on the element. - // - // Hover and active go, because a hidden element must stay hidden. A hover - // style that sets `visibility` would otherwise paint an element React - // asked to hide. - const { hover: _hover, active: _active, ...base } = instance.props.style ?? {} - rendererFor(instance).setStyle(instance.id, { ...base, visibility: "hidden" }) + stateFor(instance).hidden = true + rendererFor(instance).setStyle(instance.id, hiddenStyle(instance.props.style)) }, unhideInstance(instance: Instance, props: Props): void { + stateFor(instance).hidden = false rendererFor(instance).setStyle(instance.id, props.style ?? {}) }, From b5789d916be2f7782c365f875d3a5ca4fe489149 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:00:29 +0100 Subject: [PATCH 02/27] perf(react): diff event handlers over the props an element has --- .changeset/skip-unchanged-style-updates.md | 5 +- packages/native/js/mutations.ts | 7 ++ .../src/__tests__/event-updates.test.tsx | 86 +++++++++++++++++++ packages/react/src/reconciler/host-config.ts | 48 ++++++++--- packages/solid/src/host.ts | 4 +- 5 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 packages/react/src/__tests__/event-updates.test.tsx diff --git a/.changeset/skip-unchanged-style-updates.md b/.changeset/skip-unchanged-style-updates.md index 2e5ae6e1d..87bd84890 100644 --- a/.changeset/skip-unchanged-style-updates.md +++ b/.changeset/skip-unchanged-style-updates.md @@ -1,10 +1,11 @@ --- '@gpuix/react': patch '@gpuix/native': patch +'@gpuix/solid': patch --- -A re-render no longer resends styles and custom props that did not change. React calls `commitUpdate` for every element a parent re-rendered, and the reconciler used to serialize and send each one's `style` every time, which cost a JSON round trip and a style intern per element per commit. It now compares values, so `style={{ ... }}` rebuilt with the same contents sends nothing. The custom prop diff is also linear instead of quadratic in the number of props. +A re-render no longer resends styles and custom props that did not change. React calls `commitUpdate` for every element a parent re-rendered, and the reconciler used to serialize and send each one's `style` every time, which cost a JSON round trip and a style intern per element per commit. It now compares values, so `style={{ ... }}` rebuilt with the same contents sends nothing. The custom prop diff is linear instead of quadratic in the number of props, and the event handler diff walks the props an element has instead of every event GPUIX knows. A style change on an element React has hidden (Suspense, Activity) no longer makes it visible again: it keeps `visibility: hidden` until React unhides it. -`@gpuix/native/host` exports `sameMutationValue(left, right)` for adapters that diff the same payloads. +`@gpuix/native/host` exports `sameMutationValue(left, right)` and `EVENT_TYPE_BY_PROP` for adapters that diff the same payloads. diff --git a/packages/native/js/mutations.ts b/packages/native/js/mutations.ts index 857eebff8..ebf06411d 100644 --- a/packages/native/js/mutations.ts +++ b/packages/native/js/mutations.ts @@ -103,6 +103,13 @@ export const EVENT_PROP_NAMES: ReadonlySet = new Set( EVENT_PROPS.map(([name]) => name) ) +/** + * Event type for a handler prop. Adapters walk an element's own props and look + * each one up here, which beats testing every entry of `EVENT_PROPS` against an + * element that has two or three props. + */ +export const EVENT_TYPE_BY_PROP: ReadonlyMap = new Map(EVENT_PROPS) + export const RESERVED_PROPS: ReadonlySet = new Set([ "style", "className", diff --git a/packages/react/src/__tests__/event-updates.test.tsx b/packages/react/src/__tests__/event-updates.test.tsx new file mode 100644 index 000000000..3480fb986 --- /dev/null +++ b/packages/react/src/__tests__/event-updates.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +const box = { width: 100, height: 100 } + +describeNative("event handler updates", () => { + it("wires a handler added on a later render", () => { + const { render, renderer, unmount } = createTestRoot() + const onClick = vi.fn() + try { + render(
) + renderer.nativeSimulateClick(10, 10) + expect(onClick).not.toHaveBeenCalled() + + render(
) + renderer.nativeSimulateClick(10, 10) + expect(onClick).toHaveBeenCalledTimes(1) + } finally { + unmount() + } + }) + + it("calls only the latest handler after it is replaced", () => { + const { render, renderer, unmount } = createTestRoot() + const first = vi.fn() + const second = vi.fn() + try { + render(
) + render(
) + renderer.nativeSimulateClick(10, 10) + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + } finally { + unmount() + } + }) + + it("stops calling a handler whose prop is no longer passed", () => { + const { render, renderer, unmount } = createTestRoot() + const onClick = vi.fn() + try { + render(
) + render(
) + renderer.nativeSimulateClick(10, 10) + + expect(onClick).not.toHaveBeenCalled() + expect(renderer.findByType("div")[0]!.events.has("click")).toBe(false) + } finally { + unmount() + } + }) + + it("stops calling a handler that is set to undefined", () => { + const { render, renderer, unmount } = createTestRoot() + const onClick = vi.fn() + try { + render(
) + render(
) + renderer.nativeSimulateClick(10, 10) + + expect(onClick).not.toHaveBeenCalled() + expect(renderer.findByType("div")[0]!.events.has("click")).toBe(false) + } finally { + unmount() + } + }) + + it("keeps handlers for other events when one is removed", () => { + const { render, renderer, unmount } = createTestRoot() + const onClick = vi.fn() + const onMouseDown = vi.fn() + try { + render(
) + render(
) + + const events = renderer.findByType("div")[0]!.events + expect(events.has("click")).toBe(false) + expect(events.has("mouseDown")).toBe(true) + } finally { + unmount() + } + }) +}) diff --git a/packages/react/src/reconciler/host-config.ts b/packages/react/src/reconciler/host-config.ts index 8c875c430..5a5a21ce8 100644 --- a/packages/react/src/reconciler/host-config.ts +++ b/packages/react/src/reconciler/host-config.ts @@ -6,9 +6,10 @@ import { createContext } from "react" import { DefaultEventPriority } from "react-reconciler/constants.js" +import type { HostEventHandler } from "@gpuix/native/host" import { BUILT_IN_TYPES, - EVENT_PROPS, + EVENT_TYPE_BY_PROP, isReservedProp, sameMutationValue, serializeCustomProp, @@ -97,35 +98,62 @@ function nextId(container: Container): number { // ── Event wiring helpers ───────────────────────────────────────────── function syncEventListeners(container: Container, id: number, props: Props): void { - for (const [propName, eventType] of EVENT_PROPS) { - const handler = props[propName] - if (handler) { - registerEventHandler(container.eventHandlers, id, eventType, handler) + const record = props as Record + for (const propName in record) { + const eventType = EVENT_TYPE_BY_PROP.get(propName) + const handler = record[propName] + if (eventType && handler) { + registerEventHandler( + container.eventHandlers, + id, + eventType, + handler as HostEventHandler + ) container.renderer.setEventListener(id, eventType, true) } } } +/** + * Walks the props an element has, not every event GPUIX knows: this runs for + * each re-rendered host element, and most have a few props and no handlers. + */ function diffEventListeners( container: Container, id: number, oldProps: Props, newProps: Props ): void { - for (const [propName, eventType] of EVENT_PROPS) { - const oldHandler = oldProps[propName] - const newHandler = newProps[propName] - + const previous = oldProps as Record + const next = newProps as Record + for (const propName in next) { + const eventType = EVENT_TYPE_BY_PROP.get(propName) + if (!eventType) continue + const oldHandler = previous[propName] + const newHandler = next[propName] if (oldHandler && !newHandler) { unregisterEventHandler(container.eventHandlers, id, eventType) container.renderer.setEventListener(id, eventType, false) } else if (newHandler && newHandler !== oldHandler) { - registerEventHandler(container.eventHandlers, id, eventType, newHandler) + registerEventHandler( + container.eventHandlers, + id, + eventType, + newHandler as HostEventHandler + ) if (!oldHandler) { container.renderer.setEventListener(id, eventType, true) } } } + for (const propName in previous) { + if (propName in next) continue + const eventType = EVENT_TYPE_BY_PROP.get(propName) + if (eventType && previous[propName]) { + unregisterEventHandler(container.eventHandlers, id, eventType) + container.renderer.setEventListener(id, eventType, false) + } + } } // ── Style helper ───────────────────────────────────────────────────── diff --git a/packages/solid/src/host.ts b/packages/solid/src/host.ts index 36b12a876..32d690a42 100644 --- a/packages/solid/src/host.ts +++ b/packages/solid/src/host.ts @@ -2,8 +2,8 @@ // native elements and event contract. import { BUILT_IN_TYPES, - EVENT_PROPS, EVENT_PROP_NAMES, + EVENT_TYPE_BY_PROP, UNIVERSAL_PROPS, registerEventHandler, serializeCustomProp, @@ -146,7 +146,7 @@ export function replaceHostText(node: HostText, value: string): void { } function eventTypeFor(name: string): string | undefined { - return EVENT_PROPS.find(([prop]) => prop === name)?.[1] + return EVENT_TYPE_BY_PROP.get(name) } function customPropAllowed(node: HostElement, name: string): boolean { From d47fb1d23363505ee16bb399432c5a4d49c87fff Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:00:37 +0100 Subject: [PATCH 03/27] feat(react): createPortal mounts children into an element --- .changeset/create-portal.md | 5 + README.md | 32 ++++++ packages/react/src/__tests__/portal.test.tsx | 102 +++++++++++++++++++ packages/react/src/index.ts | 1 + packages/react/src/reconciler/host-config.ts | 44 +++++++- packages/react/src/reconciler/portal.ts | 39 +++++++ packages/react/src/types/host.ts | 7 ++ 7 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 .changeset/create-portal.md create mode 100644 packages/react/src/__tests__/portal.test.tsx create mode 100644 packages/react/src/reconciler/portal.ts diff --git a/.changeset/create-portal.md b/.changeset/create-portal.md new file mode 100644 index 000000000..c9cc5e39e --- /dev/null +++ b/.changeset/create-portal.md @@ -0,0 +1,5 @@ +--- +'@gpuix/react': minor +--- + +Added `createPortal(children, target)`, which mounts children into an element that is already mounted. Take `target` from a `ref`. The portal's children are appended to the target and kept in key order, so a toast layer, a drag preview or an overlay root no longer needs a patched host config. See "Portals" in the README. diff --git a/README.md b/README.md index ac02e031e..67140175b 100644 --- a/README.md +++ b/README.md @@ -2209,6 +2209,38 @@ A filled child of a click target (switch thumb, radio dot, check icon) needs **`pointerEvents: "none"`**, or it eats the parent's click. For Select and Combobox rows, use the item primitive's `asChild` prop instead. +### Portals + +`createPortal(children, target)` mounts `children` as the children of an element +that is already mounted, wherever the calling component sits in the tree. Use it +for a toast layer, a drag preview, or any content that belongs to another part of +the window. Context still flows from the caller, as in React DOM. + +Take `target` from a `ref` and render the portal once it is set. A portal into an +element that has not mounted has nowhere to attach. + +```tsx +import { createPortal, type PublicInstance } from '@gpuix/react' + +function Screen() { + const [layer, setLayer] = useState(null) + return ( + <> +
+ {layer && createPortal(, layer)} + + ) +} +``` + +The portal's children are appended after whatever the target already holds, and +React keeps them in key order among themselves. `target` must belong to the same +root as the component that calls `createPortal`. + +For floating menus that must paint above a ``, use +[``](#overlay-menus) rather than a portal: a portal changes where +elements live in the tree, not the order gpui paints them in. + ### Measure an element `getElementBounds(id)` returns the last painted box, or `null` if that node did diff --git a/packages/react/src/__tests__/portal.test.tsx b/packages/react/src/__tests__/portal.test.tsx new file mode 100644 index 000000000..22eb59599 --- /dev/null +++ b/packages/react/src/__tests__/portal.test.tsx @@ -0,0 +1,102 @@ +import { useState } from "react" +import { describe, expect, it } from "vitest" +import { createPortal } from "../reconciler/portal.js" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" +import type { PublicInstance } from "../types/host.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +function Screen({ items, mounted = true }: { items: string[]; mounted?: boolean }) { + const [layer, setLayer] = useState(null) + return ( +
+
+
+ {mounted && + layer && + createPortal( + items.map((item) => ( + + {item} + + )), + layer + )} +
+
+ ) +} + +describeNative("createPortal", () => { + it("mounts its children under the target element, not the lexical parent", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render() + + const layer = renderer.findByTestId("layer")! + const a = renderer.findByTestId("item-a")! + const b = renderer.findByTestId("item-b")! + + expect(layer.children).toEqual([a.id, b.id]) + expect(a.parentId).toBe(layer.id) + expect(renderer.findByTestId("lexical-parent")!.children).toEqual([]) + expect(renderer.getPaintedText()).toEqual(["a", "b"]) + } finally { + unmount() + } + }) + + it("places a child React inserts before another one in that order", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render() + const b = renderer.findByTestId("item-b")! + const c = renderer.findByTestId("item-c")! + + render() + render() + + const layer = renderer.findByTestId("layer")! + const ids = (names: string[]) => names.map((name) => renderer.findByTestId(`item-${name}`)!.id) + expect(layer.children).toEqual(ids(["a", "b", "x", "c"])) + // Neighbours are updated in place, not remounted. + expect(renderer.findByTestId("item-b")!.id).toBe(b.id) + expect(renderer.findByTestId("item-c")!.id).toBe(c.id) + } finally { + unmount() + } + }) + + it("keeps the portal mounted across re-renders", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render() + const first = renderer.findByTestId("item-a")!.id + + render() + render() + + expect(renderer.findByTestId("item-a")!.id).toBe(first) + } finally { + unmount() + } + }) + + it("frees the portal's elements when it unmounts", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render() + const without = renderer.getRetainedElementCount() + + render() + expect(renderer.getRetainedElementCount()).toBeGreaterThan(without) + + render() + expect(renderer.findByTestId("item-a")).toBeUndefined() + expect(renderer.findByTestId("layer")!.children).toEqual([]) + expect(renderer.getRetainedElementCount()).toBe(without) + } finally { + unmount() + } + }) +}) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9b0111a73..5fd01b6e4 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,5 +1,6 @@ // GPUIX React - React bindings for GPUI export { createRoot, flushSync } from "./reconciler/index.js" +export { createPortal } from "./reconciler/portal.js" export { createRenderer, enableAutomation, diff --git a/packages/react/src/reconciler/host-config.ts b/packages/react/src/reconciler/host-config.ts index 5a5a21ce8..5aa765188 100644 --- a/packages/react/src/reconciler/host-config.ts +++ b/packages/react/src/reconciler/host-config.ts @@ -280,6 +280,26 @@ function materialize(node: HostNode): HostNodeState { return state } +const portalContainers = new WeakMap() + +/** + * The container a portal into `target` mounts through. + * + * Cached per target because React compares a portal's `containerInfo` by + * identity, and a new object on every render would unmount and remount the + * portal's whole subtree. + */ +export function portalContainerFor(target: Instance): Container { + let container = portalContainers.get(target) + if (!container) { + container = Object.assign(Object.create(stateFor(target).container) as Container, { + portalTarget: target, + }) + portalContainers.set(target, container) + } + return container +} + // ── Host config ────────────────────────────────────────────────────── export const hostConfig = { @@ -338,11 +358,19 @@ export const hostConfig = { parentState.container.renderer.insertBefore(parent.id, child.id, beforeChild.id) }, + // A window's root has one child, so there is nothing to order. A portal's + // children share their target with whatever else it holds, and React places + // one before another whenever their keys reorder. insertInContainerBefore( - _parent: Container, - _child: Instance, - _beforeChild: Instance - ): void {}, + container: Container, + child: Instance, + beforeChild: Instance + ): void { + const target = container.portalTarget + if (!target) return + materialize(child) + container.renderer.insertBefore(target.id, child.id, beforeChild.id) + }, removeChildFromContainer(parent: Container, child: Instance): void { const destroyed = parent.renderer.destroyElement(child.id) @@ -458,7 +486,13 @@ export const hostConfig = { appendChildToContainer(container: Container, child: Instance): void { materialize(child) - container.renderer.setRoot(child.id) + const target = container.portalTarget + if (target) { + materialize(target) + container.renderer.appendChild(target.id, child.id) + } else { + container.renderer.setRoot(child.id) + } }, appendInitialChild(parent: Instance, child: Instance | TextInstance): void { diff --git a/packages/react/src/reconciler/portal.ts b/packages/react/src/reconciler/portal.ts new file mode 100644 index 000000000..881e652e0 --- /dev/null +++ b/packages/react/src/reconciler/portal.ts @@ -0,0 +1,39 @@ +import type { ReactNode } from "react" +import type { PublicInstance } from "../types/host.js" +import { portalContainerFor } from "./host-config.js" + +const REACT_PORTAL_TYPE = Symbol.for("react.portal") + +/** + * Render `children` as children of `target`, an element that is already + * mounted, in the order React places them. The content keeps the context of the + * component that called `createPortal`, as in React DOM. + * + * Take `target` from a `ref` and render the portal once it is set: a portal + * into an element that is not mounted yet has nowhere to attach. + * + * ```tsx + * const [layer, setLayer] = useState(null) + * return ( + * <> + *
+ * {layer && createPortal(, layer)} + * + * ) + * ``` + * + * `target` must belong to the same root as the caller. + */ +export function createPortal( + children: ReactNode, + target: PublicInstance, + key?: string | null +): ReactNode { + return { + $$typeof: REACT_PORTAL_TYPE, + key: key == null ? null : String(key), + children, + containerInfo: portalContainerFor(target), + implementation: null, + } as unknown as ReactNode +} diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index c97af5679..57d5c6275 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -91,6 +91,13 @@ export interface Container { windowKeyEventId: number windowSelectionEventId: number onEvent?: (event: EventPayload) => void + /** + * Set on a portal's container. Its children mount under this element instead + * of becoming the window's root. The container inherits everything else from + * the root container it was made from, so both share one mutation queue and + * one event map. + */ + portalTarget?: Instance } export interface Instance { From 782607bf219713b448b743b03e03fa969e2dbf41 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:00:59 +0100 Subject: [PATCH 04/27] perf(native): repaint only when a batch changed something --- .changeset/invalidate-only-on-change.md | 5 ++ packages/native/src/renderer.rs | 109 ++++++++++++++++++++++-- packages/native/src/retained_tree.rs | 58 +++++++++++-- 3 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 .changeset/invalidate-only-on-change.md diff --git a/.changeset/invalidate-only-on-change.md b/.changeset/invalidate-only-on-change.md new file mode 100644 index 000000000..a70c3ce09 --- /dev/null +++ b/.changeset/invalidate-only-on-change.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +`applyBatch` asks the window to repaint only when the batch changed something a frame would draw. A batch that only resends the style, text, listeners or custom props an element already has no longer rebuilds and re-lays-out the whole element tree for a frame that comes out identical. diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 3707b907e..6a885c533 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -1385,8 +1385,14 @@ impl GpuixRenderer { let mut tree = self.tree.lock().unwrap(); let destroyed = apply_batch_to_tree(&mut tree, json.as_bytes()).map_err(Error::from_reason)?; + let dirty = tree.take_dirty(); drop(tree); - self.request_invalidate()?; + // A re-render that changed nothing sends a batch of identical resends. + // Waking the window for it makes it rebuild and diff the whole element + // tree to draw the frame it already has. + if dirty { + self.request_invalidate()?; + } Ok(destroyed) } @@ -2881,9 +2887,14 @@ impl WebGpuixRenderer { &self, json: String, ) -> Result { - let destroyed = apply_batch_to_tree(&mut self.tree.lock().unwrap(), json.as_bytes()) + let mut tree = self.tree.lock().unwrap(); + let destroyed = apply_batch_to_tree(&mut tree, json.as_bytes()) .map_err(|error| wasm_bindgen::JsValue::from_str(&error))?; - notify_web(); + let dirty = tree.take_dirty(); + drop(tree); + if dirty { + notify_web(); + } Ok(web_number_array(destroyed)) } @@ -4665,7 +4676,7 @@ impl GpuixView { window: &gpui::Window, cx: &gpui::App, ) -> Option> { - if !tree.keyboard_focus_dim || !window.last_input_was_keyboard() { + if !tree.keyboard_focus_dim() || !window.last_input_was_keyboard() { return None; } let focused = window.focused(cx)?.id(); @@ -6448,10 +6459,10 @@ pub fn apply_batch_to_tree(tree: &mut RetainedTree, bytes: &[u8]) -> BatchResult tree.set_event_listener(id, event_type, has_handler); } BatchOp::SetRoot { id } => { - tree.root_id = Some(id); + tree.set_root(id); } BatchOp::SetKeyboardFocusDim { enabled } => { - tree.keyboard_focus_dim = enabled; + tree.set_keyboard_focus_dim(enabled); } BatchOp::SetCustomProp { id, key, value } => { tree.set_custom_prop(id, key, value); @@ -7087,6 +7098,92 @@ mod batch_tests { out } + /// Whether the batch left something a frame would draw differently. The + /// window is only woken for these, so a wrong `false` is a stale screen and + /// a wrong `true` is a wasted frame. + fn dirty_after(tree: &mut RetainedTree, json: &str) -> bool { + apply(tree, json).expect("valid batch"); + tree.take_dirty() + } + + const MOUNT: &str = r##"[ + ["createElement",1,"div"],["createElement",2,"text"],["createElement",3,"div"], + ["appendChild",1,2],["appendChild",1,3],["setRoot",1], + ["setStyle",1,{"width":100,"backgroundColor":"#112233"}], + ["setStyle",3,{"height":10}], + ["setText",2,"hello"], + ["setEventListener",3,"click",true], + ["setCustomProp",3,"aria-label","row"], + ["setCustomProp",3,"testId","row"], + ["setCustomProp",3,"autoFocus",true] + ]"##; + + #[test] + fn mounting_marks_the_tree_dirty() { + let mut tree = RetainedTree::new(); + assert!(dirty_after(&mut tree, MOUNT)); + } + + #[test] + fn resending_everything_unchanged_is_not_dirty() { + let mut tree = RetainedTree::new(); + apply(&mut tree, MOUNT).expect("valid batch"); + tree.take_dirty(); + + // The same ops again, with the style keys reordered: interning keys on + // raw bytes, so this reaches `set_style` as a different `Arc`. + let resend = r##"[ + ["setRoot",1], + ["setStyle",1,{"backgroundColor":"#112233","width":100}], + ["setStyle",3,{"height":10}], + ["setText",2,"hello"], + ["setEventListener",3,"click",true], + ["setCustomProp",3,"aria-label","row"], + ["setCustomProp",3,"testId","row"], + ["setCustomProp",3,"autoFocus",true], + ["setKeyboardFocusDim",true], + ["setCustomProp",3,"missing",null] + ]"##; + assert!(!dirty_after(&mut tree, resend)); + } + + #[test] + fn each_kind_of_change_is_dirty() { + let changes = [ + r#"[["setStyle",3,{"height":11}]]"#, + r#"[["setText",2,"changed"]]"#, + r#"[["setEventListener",3,"click",false]]"#, + r#"[["setEventListener",3,"mouseDown",true]]"#, + r#"[["setCustomProp",3,"aria-label","other"]]"#, + r#"[["setCustomProp",3,"aria-label",null]]"#, + r#"[["setCustomProp",3,"testId","other"]]"#, + r#"[["setCustomProp",3,"autoFocus",false]]"#, + r#"[["setKeyboardFocusDim",false]]"#, + r#"[["createElement",4,"div"]]"#, + r#"[["destroyElement",3]]"#, + r#"[["insertBefore",1,3,2]]"#, + r#"[["setRoot",3]]"#, + ]; + for change in changes { + let mut tree = RetainedTree::new(); + apply(&mut tree, MOUNT).expect("valid batch"); + tree.take_dirty(); + assert!(dirty_after(&mut tree, change), "{change} must repaint"); + } + } + + #[test] + fn a_rejected_batch_does_not_leave_the_tree_dirty() { + let mut tree = RetainedTree::new(); + apply(&mut tree, MOUNT).expect("valid batch"); + tree.take_dirty(); + + apply(&mut tree, r#"[["setText",2,"changed"],["setStyle",2,123]]"#) + .expect_err("malformed style"); + + assert!(!tree.take_dirty()); + } + /// The regression test for batch atomicity. `intern_style_payload` used to /// run inside the apply loop, so this batch created the element, set its /// text, and only then threw — leaving JS to retry against a tree that had diff --git a/packages/native/src/retained_tree.rs b/packages/native/src/retained_tree.rs index cbc68c99d..045b894fb 100644 --- a/packages/native/src/retained_tree.rs +++ b/packages/native/src/retained_tree.rs @@ -189,8 +189,13 @@ pub struct RetainedTree { /// The root element ID set by appendChildToContainer. pub root_id: Option, /// Dim other focusables while a control has keyboard focus. Set per root. - pub keyboard_focus_dim: bool, + keyboard_focus_dim: bool, next_revision: u64, + /// Something a frame would draw changed since the last `take_dirty`. A batch + /// of resends, which is what a re-render with unchanged props produces, + /// leaves it false, so the window is not asked to rebuild its element tree + /// for a frame that would come out identical. + dirty: bool, } impl RetainedTree { @@ -202,16 +207,45 @@ impl RetainedTree { root_id: None, keyboard_focus_dim: true, next_revision: 1, + dirty: false, + } + } + + /// Whether a frame drawn now would differ from the last one, clearing the + /// answer. Call once per applied batch. + pub fn take_dirty(&mut self) -> bool { + std::mem::take(&mut self.dirty) + } + + pub fn keyboard_focus_dim(&self) -> bool { + self.keyboard_focus_dim + } + + pub fn set_keyboard_focus_dim(&mut self, enabled: bool) { + if self.keyboard_focus_dim != enabled { + self.keyboard_focus_dim = enabled; + self.dirty = true; + } + } + + pub fn set_root(&mut self, id: u64) { + if self.root_id != Some(id) { + self.root_id = Some(id); + self.dirty = true; } } pub fn create_element(&mut self, id: u64, element_type: String) { + self.dirty = true; let revision = self.take_revision(); self.elements .insert(id, RetainedElement::new(id, element_type, revision)); } fn take_revision(&mut self) -> u64 { + // Every content change takes a revision to invalidate caches, so this is + // the one place that covers style, text, structure and custom props. + self.dirty = true; let revision = self.next_revision; self.next_revision = self.next_revision.wrapping_add(1).max(1); revision @@ -258,6 +292,9 @@ impl RetainedTree { } let mut destroyed = Vec::new(); self.destroy_element_recursive(id, &mut destroyed); + if !destroyed.is_empty() { + self.dirty = true; + } if self.root_id == Some(id) { self.root_id = None; } @@ -364,11 +401,14 @@ impl RetainedTree { pub fn set_event_listener(&mut self, id: u64, event_type: String, has_handler: bool) { if let Some(element) = self.elements.get_mut(&id) { - if has_handler { - element.events.insert(event_type); + let changed = if has_handler { + element.events.insert(event_type) } else { - element.events.remove(&event_type); - } + element.events.remove(&event_type) + }; + // Which listeners exist decides how the element is built, though it + // does not touch any cached text. + self.dirty |= changed; } } @@ -390,11 +430,15 @@ impl RetainedTree { // `autoFocus` applies to every element type, so it is lifted out of // the custom-prop map that only custom elements read. if key == "autoFocus" { - element.auto_focus = value.as_bool().unwrap_or(false); + let auto_focus = value.as_bool().unwrap_or(false); + self.dirty |= element.auto_focus != auto_focus; + element.auto_focus = auto_focus; return; } if key == "testId" { - element.test_id = value.as_str().map(str::to_string); + let test_id = value.as_str().map(str::to_string); + self.dirty |= element.test_id != test_id; + element.test_id = test_id; return; } if value.is_null() { From d8cf51ac86fd6fd1ac6cfcf48a4a7b383296113f Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:01:00 +0100 Subject: [PATCH 05/27] perf(native): track host bounds from paint, not an extra child node --- .changeset/track-bounds-without-a-child.md | 7 +++++ README.md | 3 ++- packages/native/src/automation.rs | 31 ++++++++++++++++++++++ packages/native/src/custom_elements/mod.rs | 8 +++++- packages/native/src/renderer.rs | 23 ++++++++++------ packages/native/src/text/paint.rs | 4 +-- 6 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 .changeset/track-bounds-without-a-child.md diff --git a/.changeset/track-bounds-without-a-child.md b/.changeset/track-bounds-without-a-child.md new file mode 100644 index 000000000..f4789738a --- /dev/null +++ b/.changeset/track-bounds-without-a-child.md @@ -0,0 +1,7 @@ +--- +'@gpuix/native': patch +--- + +`
` and `` no longer add a hidden child element to record their bounds. Each host element used to carry an extra `canvas` node, which doubled the nodes gpui laid out, prepainted and painted every frame. The bounds now come from the element's own paint, so a frame over the same tree is about 15% cheaper. An element with `visibility: hidden` keeps the child, because gpui does not paint a hidden element and its layout box must stay readable. + +`getElementBounds()`, automation locators and text selection regions now use the element's border box. They used the padding box before, which was narrower by the border width on an element with a `borderWidth`. diff --git a/README.md b/README.md index 67140175b..0cd2330ad 100644 --- a/README.md +++ b/README.md @@ -2246,7 +2246,8 @@ elements live in the tree, not the order gpui paints them in. `getElementBounds(id)` returns the last painted box, or `null` if that node did not paint. It works on the live `GpuixRenderer` and on the test renderer. Bounds are recorded during **paint**, so read them after a frame, not in the -same commit as mount. +same commit as mount. The box is the element's border box, like a DOM +`getBoundingClientRect`. ```tsx const box = renderer.getElementBounds?.(ref.current.id) diff --git a/packages/native/src/automation.rs b/packages/native/src/automation.rs index 41ab83cbb..c9ed5ec4c 100644 --- a/packages/native/src/automation.rs +++ b/packages/native/src/automation.rs @@ -69,6 +69,37 @@ pub fn track_own_bounds(el: E, id: u64) -> E { el.on_painted(move |bounds, _, _| record_bounds(id, bounds)) } +/// Record a host element's painted box, and its selection-start region, for +/// automation and text selection. +/// +/// A visible element reports its own box through `on_painted`, so tracking it +/// adds nothing to the tree. The previous `bounds_tracker` child doubled the +/// number of nodes gpui laid out, prepainted and painted for every `
` and +/// ``. +/// +/// gpui does not call `on_painted` for `visibility: hidden`, and a hidden +/// element still has a layout box that a locator or a measurement is entitled +/// to read. `hidden` keeps the canvas for exactly those elements. The canvas is +/// `absolute`, so the element must be a positioned parent. +/// +/// The two paths differ by an element's border: the canvas fills the padding +/// box, and `on_painted` reports the border box, which is what a DOM +/// `getBoundingClientRect` reports. +pub fn track_host_bounds(el: E, id: u64, selection_start: Option, hidden: bool) -> E +where + E: gpui::InteractiveElement + gpui::ParentElement, +{ + if hidden { + return el.child(bounds_tracker(id, selection_start)); + } + el.on_painted(move |bounds, _, _| { + record_bounds(id, bounds); + if let Some(selectable) = selection_start { + crate::text::record_start_region(bounds, selectable); + } + }) +} + pub fn record_bounds(id: u64, bounds: Bounds) { BOUNDS.with(|cell| { cell.borrow_mut() diff --git a/packages/native/src/custom_elements/mod.rs b/packages/native/src/custom_elements/mod.rs index 559656412..e861fe0c5 100644 --- a/packages/native/src/custom_elements/mod.rs +++ b/packages/native/src/custom_elements/mod.rs @@ -128,7 +128,13 @@ pub(crate) fn custom_surface( { el = el.relative(); } - el = el.child(crate::automation::bounds_tracker(ctx.id, None)); + el = crate::automation::track_host_bounds( + el, + ctx.id, + None, + ctx.style + .is_some_and(|style| style.visibility.as_deref() == Some("hidden")), + ); el = crate::accessibility::apply_accessibility(el, ctx.props, None); wire_standard_events(el, ctx) } diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 6a885c533..654f8413d 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -4974,7 +4974,10 @@ pub(crate) fn build_element( ) -> gpui::AnyElement { use gpui::IntoElement; - let Some(element) = ctx.tree.elements.get(&id) else { + // Copy the tree reference out of `ctx`, so `element` borrows the tree and + // not the build context, which the children below need mutably. + let tree = ctx.tree; + let Some(element) = tree.elements.get(&id) else { return gpui::Empty.into_any_element(); }; @@ -5233,18 +5236,17 @@ fn joined_text_content( if let Some(content) = element.content.as_deref().filter(|value| !value.is_empty()) { return Some(content.to_string()); } - let mut parts = Vec::new(); + let mut joined = String::new(); for child_id in &element.children { let Some(child) = tree.elements.get(child_id) else { continue; }; if child.element_type == "text" { if let Some(content) = child.content.as_deref() { - parts.push(content); + joined.push_str(content); } } } - let joined = parts.concat(); (!joined.is_empty()).then_some(joined) } @@ -5352,10 +5354,12 @@ pub(crate) fn build_host_container( if style.and_then(|style| style.position.as_deref()).is_none() { el = el.relative(); } - el = el.child(crate::automation::bounds_tracker( + el = crate::automation::track_host_bounds( + el, element.id, selection_start_flag(style), - )); + is_hidden(style), + ); if let Some(handle) = ctx.focus_handles.get(&element.id) { el = el.track_focus(handle); @@ -5611,8 +5615,7 @@ pub(crate) fn build_host_container( } // Children - let child_ids: Vec = element.children.clone(); - for child_id in child_ids { + for &child_id in &element.children { let child = build_element(child_id, ctx, window, cx); el = if overflow_x_only { el.child(gpui::div().flex_none().child(child)) @@ -5657,6 +5660,10 @@ fn text_content( }) } +fn is_hidden(style: Option<&StyleDesc>) -> bool { + style.is_some_and(|style| style.visibility.as_deref() == Some("hidden")) +} + /// Explicit `userSelect` on this node. `None` means inherit; the ancestor /// that set the value already owns the start region. fn selection_start_flag(style: Option<&StyleDesc>) -> Option { diff --git a/packages/native/src/text/paint.rs b/packages/native/src/text/paint.rs index 9af978ecb..23005b0cc 100644 --- a/packages/native/src/text/paint.rs +++ b/packages/native/src/text/paint.rs @@ -114,8 +114,8 @@ pub fn selection_frame_reset( /// Record a selection-start region from an element's painted box. /// -/// Only `bounds_tracker` calls this, so a start region is always the same box -/// automation already uses. Last painted region that contains the point wins. +/// Only `track_host_bounds` and `bounds_tracker` call this, so a start region is +/// always the same box automation already uses. Last painted region that contains the point wins. pub fn record_start_region(bounds: Bounds, selectable: bool) { START_REGIONS.with(|r| r.borrow_mut().push(StartRegion { bounds, selectable })); } From ef1433952d65a0814338ad24dc6ca08d40d6b1b8 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:01:08 +0100 Subject: [PATCH 06/27] test: benchmark a list that re-renders with nothing visibly changed --- examples/package.json | 2 +- examples/profile-rerender.tsx | 39 +++++++++++++ examples/rerender-fixture.tsx | 62 +++++++++++++++++++++ examples/rerender.perf.test.tsx | 99 +++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 examples/profile-rerender.tsx create mode 100644 examples/rerender-fixture.tsx create mode 100644 examples/rerender.perf.test.tsx diff --git a/examples/package.json b/examples/package.json index 2c6d4ab6d..4896a652e 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,7 +7,7 @@ "counter": "bun --hot counter.tsx", "diff": "bun --hot diff.tsx", "test": "vitest run --exclude '**/*.perf.test.tsx'", - "test:perf": "vitest run chat.perf.test.tsx timeline.perf.test.tsx", + "test:perf": "vitest run chat.perf.test.tsx timeline.perf.test.tsx rerender.perf.test.tsx", "native-text": "bun --hot native-text.tsx", "chat": "bun --hot chat.tsx", "chat-solid": "bun --hot solid/chat.tsx", diff --git a/examples/profile-rerender.tsx b/examples/profile-rerender.tsx new file mode 100644 index 000000000..c43d76c0b --- /dev/null +++ b/examples/profile-rerender.tsx @@ -0,0 +1,39 @@ +/** + * Loops the frame that follows a header-only update over the chat-shaped list, + * so a sampling profiler has something to look at. + * + * bun profile-rerender.tsx & + * sample $! 5 -file /tmp/rerender.txt + * + * `MODE=commit` loops React's commit instead of the native frame. + */ + +import React from 'react' +import { flushSync } from '@gpuix/react' +import { createTestRoot } from '@gpuix/react/testing' +import { App } from './rerender-fixture' + +const mode = process.env.MODE ?? 'frame' +const seconds = Number(process.env.SECONDS ?? '12') +const root = createTestRoot({ width: 900, height: 700 }) +root.render() +root.renderer.flush() + +const end = performance.now() + seconds * 1000 +let tick = 0 +let frames = 0 +let spent = 0 +while (performance.now() < end) { + const start = performance.now() + flushSync(() => root.root.render()) + if (mode === 'frame') { + const committed = performance.now() + root.renderer.flush() + spent += performance.now() - committed + } else { + spent += performance.now() - start + } + frames++ +} +console.log(JSON.stringify({ mode, frames, avgMs: +(spent / frames).toFixed(2) })) +process.exit(0) diff --git a/examples/rerender-fixture.tsx b/examples/rerender-fixture.tsx new file mode 100644 index 000000000..9197a5a03 --- /dev/null +++ b/examples/rerender-fixture.tsx @@ -0,0 +1,62 @@ +/** + * A chat-shaped list for the re-render benchmarks. Every row builds its `style` + * objects and handlers from scratch on each render, the way an app that does not + * hoist its styles does, so a parent update re-renders all of them with values + * that are equal but never identical. + */ + +import React from 'react' + +export const ROWS = 1_000 + +function Row({ index, body }: { index: number; body: string }) { + return ( +
+
+
+ user {index} + 12:{String(index % 60).padStart(2, '0')} +
+ {body} +
+
{}} + style={{ padding: 4, borderRadius: 4, backgroundColor: '#313244', hover: { backgroundColor: '#45475a' } }} + > + copy +
+
{}} + style={{ padding: 4, borderRadius: 4, backgroundColor: '#313244', hover: { backgroundColor: '#45475a' } }} + > + retry +
+
+
+ ) +} + +/** `tick` changes on every update and reaches no style, so every row repeats itself. */ +export function App({ tick, streaming }: { tick: number; streaming: string }) { + return ( +
+ tick {tick} +
+ {Array.from({ length: ROWS }, (_, index) => ( + + ))} +
+
+ ) +} diff --git a/examples/rerender.perf.test.tsx b/examples/rerender.perf.test.tsx new file mode 100644 index 000000000..399ccbcc2 --- /dev/null +++ b/examples/rerender.perf.test.tsx @@ -0,0 +1,99 @@ +/** + * Re-render cost when nothing visible changed. This is the shape of a chat + * client: a parent holds state (composer text, a streaming message, a clock), + * every row below it re-renders, and each row builds its `style` object and its + * handlers from scratch. React cannot skip them, so what matters is how cheap + * the renderer makes "the same thing again". + * + * Reports the mutations that cross into Rust per update next to the time, since + * an update that sends nothing cannot cost a frame. Skip without the native GPU + * test renderer. Excluded from `bun run test` by the `*.perf.test.tsx` glob. + */ + +import React from 'react' +import { describe, expect, it } from 'vitest' +import { flushSync } from '@gpuix/react' +import { createTestRoot, hasNativeTestRenderer, type TestRoot } from '@gpuix/react/testing' +import { App } from './rerender-fixture' + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +const WARMUP = 5 +const SAMPLES = 30 + +function measure( + root: TestRoot, + next: (index: number) => React.ReactNode, +): { ms: number[]; frameMs: number[]; ops: number; bytes: number } { + const { renderer } = root + let ops = 0 + let bytes = 0 + const original = renderer.applyBatch.bind(renderer) + renderer.applyBatch = (json: string) => { + bytes += json.length + ops += (JSON.parse(json) as unknown[]).length + return original(json) + } + // `ms` is React rendering and committing, which includes `applyBatch`. + // `frameMs` is the native frame that follows. A real window only runs one when + // a batch left something dirty, but the test renderer always does. Not + // `root.render()`, which flushes a frame of its own and would count it twice. + const ms: number[] = [] + const frameMs: number[] = [] + try { + for (let i = 0; i < WARMUP + SAMPLES; i++) { + if (i === WARMUP) { + ops = 0 + bytes = 0 + } + const start = performance.now() + flushSync(() => root.root.render(next(i))) + const committed = performance.now() + renderer.flush() + if (i >= WARMUP) { + ms.push(committed - start) + frameMs.push(performance.now() - committed) + } + } + } finally { + renderer.applyBatch = original + } + return { ms, frameMs, ops: ops / SAMPLES, bytes: bytes / SAMPLES } +} + +function p(samples: number[], q: number) { + const sorted = [...samples].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1)]! +} + +function report(label: string, result: ReturnType) { + console.log( + `[rerender.perf] ${label}: commit p50=${p(result.ms, 0.5).toFixed(2)}ms p95=${p(result.ms, 0.95).toFixed(2)}ms ` + + `frame p50=${p(result.frameMs, 0.5).toFixed(2)}ms p95=${p(result.frameMs, 0.95).toFixed(2)}ms ` + + `ops/update=${result.ops.toFixed(0)} bytes/update=${result.bytes.toFixed(0)}`, + ) +} + +describeNative('re-render with nothing visibly changed', () => { + it('sends only the streaming row when the whole list re-renders', () => { + const root = createTestRoot({ width: 900, height: 700 }) + root.render() + let text = '' + const result = measure(root, (index) => { + text += 'word ' + return + }) + report('streaming row, whole list re-renders', result) + // The `tick` text and the streaming row's text are the only two changes. Each + // costs one op; anything near the row count means rows resend what they have. + expect(result.ops).toBeLessThan(10) + }, 120_000) + + it('sends one op when only a header changes', () => { + const root = createTestRoot({ width: 900, height: 700 }) + root.render() + const result = measure(root, (index) => ) + report('header text only, whole list re-renders', result) + expect(result.ops).toBeLessThan(5) + }, 120_000) +}) From ab37d93962431a0bfcdeedf5f99e257d869c3b7e Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:02:33 +0100 Subject: [PATCH 07/27] build(react): move to react-reconciler 0.33 and wait on AnimatePresence removal --- .changeset/react-reconciler-0-33.md | 5 ++++ bun.lock | 8 ++----- packages/react/package.json | 4 ++-- .../src/__tests__/animate-presence.test.tsx | 24 ++++++++++++------- 4 files changed, 24 insertions(+), 17 deletions(-) create mode 100644 .changeset/react-reconciler-0-33.md diff --git a/.changeset/react-reconciler-0-33.md b/.changeset/react-reconciler-0-33.md new file mode 100644 index 000000000..10ad56717 --- /dev/null +++ b/.changeset/react-reconciler-0-33.md @@ -0,0 +1,5 @@ +--- +'@gpuix/react': minor +--- + +`@gpuix/react` now uses `react-reconciler@^0.33.0` and peers on `react@^19.2.0`. React 19.2 pairs with reconciler 0.33, and the old `^0.31.0` pin forced every app on 19.2 to override it. The `^18.0.0` half of the old peer range was never true, since reconciler 0.31 already required React 19. diff --git a/bun.lock b/bun.lock index 59c0297fd..5096f3020 100644 --- a/bun.lock +++ b/bun.lock @@ -102,7 +102,7 @@ "version": "0.10.0", "dependencies": { "@gpuix/native": "workspace:^", - "react-reconciler": "^0.31.0", + "react-reconciler": "^0.33.0", }, "devDependencies": { "@testing-library/react": "^16.3.2", @@ -114,7 +114,7 @@ "vitest": "^4.0.18", }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", + "react": "^19.2.0", }, }, "packages/solid": { @@ -1104,8 +1104,6 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@gpuix/react/react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="], - "@isaacs/fs-minipass/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "@napi-rs/cli/obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], @@ -1146,8 +1144,6 @@ "tar/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - "@gpuix/react/react-reconciler/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], - "@napi-rs/lzma-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@napi-rs/lzma-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], diff --git a/packages/react/package.json b/packages/react/package.json index cf2a94808..62cedd59c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -88,10 +88,10 @@ }, "dependencies": { "@gpuix/native": "workspace:^", - "react-reconciler": "^0.31.0" + "react-reconciler": "^0.33.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" + "react": "^19.2.0" }, "devDependencies": { "@testing-library/react": "^16.3.2", diff --git a/packages/react/src/__tests__/animate-presence.test.tsx b/packages/react/src/__tests__/animate-presence.test.tsx index 18465dd9a..6a771dc8b 100644 --- a/packages/react/src/__tests__/animate-presence.test.tsx +++ b/packages/react/src/__tests__/animate-presence.test.tsx @@ -1,11 +1,23 @@ import { useLayoutEffect } from "react" -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { AnimatePresence, motion, usePresence } from "../index" import { flushSync } from "../reconciler/reconciler" import { createTestRoot, hasNativeTestRenderer } from "../testing" const describeNative = hasNativeTestRenderer ? describe : describe.skip +/** + * Waits for AnimatePresence to drop a child that has nothing to animate. The + * removal is a state update from a passive effect, which React's scheduler runs + * on its own clock, so a fixed sleep races it. Poll for the outcome instead. + */ +async function expectRemoved(renderer: ReturnType["renderer"]) { + await vi.waitFor(() => { + renderer.flush() + expect(renderer.getAllText()).toEqual([]) + }) +} + describeNative("AnimatePresence", () => { it("keeps an exiting motion.div until the exit target finishes", () => { const { render, renderer } = createTestRoot() @@ -85,10 +97,7 @@ describeNative("AnimatePresence", () => { render() render() - await new Promise((resolve) => setTimeout(resolve, 0)) - renderer.flush() - - expect(renderer.getAllText()).toEqual([]) + await expectRemoved(renderer) }) it("exits with the latest props for a stable key", () => { @@ -467,10 +476,7 @@ describeNative("AnimatePresence", () => { render() staleRemove?.() render() - await new Promise((resolve) => setTimeout(resolve, 0)) - renderer.flush() - - expect(renderer.getAllText()).toEqual([]) + await expectRemoved(renderer) }) }) From a6506818548b74d1640fd224df73f1fbeddc8a7f Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:10:47 +0100 Subject: [PATCH 08/27] feat(native): aspectRatio, dashed borders, shadow lists, inset shadows and more alignment values --- .changeset/more-css-layout-styles.md | 13 ++ README.md | 20 +- packages/native/js/host.ts | 9 +- packages/native/src/renderer.rs | 43 +++- packages/native/src/style.rs | 52 ++++- .../react/src/__tests__/styles-css.test.tsx | 184 ++++++++++++++++++ 6 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 .changeset/more-css-layout-styles.md create mode 100644 packages/react/src/__tests__/styles-css.test.tsx diff --git a/.changeset/more-css-layout-styles.md b/.changeset/more-css-layout-styles.md new file mode 100644 index 000000000..5e645f1df --- /dev/null +++ b/.changeset/more-css-layout-styles.md @@ -0,0 +1,13 @@ +--- +'@gpuix/native': minor +'@gpuix/react': minor +'@gpuix/solid': minor +--- + +New styles for what gpui already draws and GPUIX did not expose: + +- `aspectRatio` sizes the axis that has no definite size. +- `borderStyle: "dashed"` draws every border side dashed. +- `boxShadow` accepts an array of shadows, and each shadow takes `inset: true`. +- `alignItems` accepts `"stretch"` and `"baseline"`, which `alignSelf` already did. +- `justifyContent` accepts `"space-evenly"`, which `alignContent` already did. diff --git a/README.md b/README.md index 0cd2330ad..be1e05eb0 100644 --- a/README.md +++ b/README.md @@ -3060,15 +3060,15 @@ CSS-like styling via the `style` prop: > style, or pad the parent wrapper. The input clips to its own > `borderRadius` automatically. -**Layout:** `display` (`"flex"` | `"grid"`), `flexDirection`, `flexWrap`, `flexGrow`, `flexShrink`, `flexBasis`, `alignItems`, `alignSelf`, `alignContent`, `justifyContent`, `gap`, `rowGap`, `columnGap`, `gridTemplateColumns`, `gridTemplateRows`, `gridColumnMin`, `gridRowMin` +**Layout:** `display` (`"flex"` | `"grid"`), `flexDirection`, `flexWrap`, `flexGrow`, `flexShrink`, `flexBasis`, `alignItems` (`"start"` | `"center"` | `"end"` | `"stretch"` | `"baseline"`), `alignSelf`, `alignContent`, `justifyContent` (adds `"space-between"`, `"space-around"` and `"space-evenly"`), `gap`, `rowGap`, `columnGap`, `gridTemplateColumns`, `gridTemplateRows`, `gridColumnMin`, `gridRowMin` -**Sizing:** `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `maxHeight` — accepts pixels (number) or percentages (string like `"100%"`) +**Sizing:** `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `maxHeight` — accepts pixels (number) or percentages (string like `"100%"`). `aspectRatio` (width divided by height) sizes whichever axis has no definite size **Spacing:** `padding`, `paddingTop/Right/Bottom/Left`, `margin`, `marginTop/Right/Bottom/Left` **Position:** `position` (`"relative"` | `"absolute"` | `"fixed"`), `top`, `right`, `bottom`, `left` — `"fixed"` lays out like `"absolute"`, because GPUI has no scrolling document to be fixed against -**Visual:** `background`, `backgroundColor`, `color`, `opacity`, `cursor`, `pointerEvents`, `borderRadius`, `borderTopLeftRadius`, `borderTopRightRadius`, `borderBottomLeftRadius`, `borderBottomRightRadius`, `borderWidth`, `borderTopWidth`, `borderRightWidth`, `borderBottomWidth`, `borderLeftWidth`, `borderColor`, `boxShadow`, `outlineWidth`, `outlineColor`, `outlineOffset` +**Visual:** `background`, `backgroundColor`, `color`, `opacity`, `cursor`, `pointerEvents`, `borderRadius`, `borderTopLeftRadius`, `borderTopRightRadius`, `borderBottomLeftRadius`, `borderBottomRightRadius`, `borderWidth`, `borderTopWidth`, `borderRightWidth`, `borderBottomWidth`, `borderLeftWidth`, `borderColor`, `borderStyle` (`"solid"` | `"dashed"`), `boxShadow`, `outlineWidth`, `outlineColor`, `outlineOffset` ### Cursors @@ -3159,8 +3159,9 @@ Limited relative-color forms can derive a new color from a base value: /> ``` -`boxShadow` accepts one structured shadow. Its fields are `offsetX`, `offsetY`, -`blurRadius`, `spreadRadius`, and `color`: +`boxShadow` accepts one structured shadow or an array of them, painted in the +order given. Its fields are `offsetX`, `offsetY`, `blurRadius`, `spreadRadius`, +`color`, and `inset`, which draws inside the border box: ```tsx
+ +
``` **Overflow:** `overflow`, `overflowX`, `overflowY` — `"hidden"` clips content, `"scroll"` creates a native scrollable container with persistent scroll state diff --git a/packages/native/js/host.ts b/packages/native/js/host.ts index 04e6143c0..f813d2bdd 100644 --- a/packages/native/js/host.ts +++ b/packages/native/js/host.ts @@ -100,6 +100,8 @@ export interface BoxShadow { blurRadius: number spreadRadius: number color: string + /** Draw inside the border box, like CSS `inset`. */ + inset?: boolean } export interface LinearGradientStop { @@ -177,7 +179,12 @@ export interface StyleDesc { borderTopRightRadius?: number borderBottomLeftRadius?: number borderBottomRightRadius?: number - boxShadow?: BoxShadow + /** One shadow, or a list painted in the order given. */ + boxShadow?: BoxShadow | BoxShadow[] + /** Applies to every side. */ + borderStyle?: "solid" | "dashed" + /** Width divided by height. Fills in whichever axis has no definite size. */ + aspectRatio?: number /** CSS `outline`: a line outside the border box. Takes no layout space, so it * can appear on focus without moving anything. Width defaults to 1. */ outlineWidth?: number diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 654f8413d..442054b76 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -5764,6 +5764,8 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc) -> E { Some("center") => el = el.items_center(), Some("start") | Some("flex-start") => el = el.items_start(), Some("end") | Some("flex-end") => el = el.items_end(), + Some("stretch") => el = el.items_stretch(), + Some("baseline") => el = el.items_baseline(), _ => {} } match style.align_content.as_deref() { @@ -5783,6 +5785,7 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc) -> E { Some("end") | Some("flex-end") => el = el.justify_end(), Some("between") | Some("space-between") => el = el.justify_between(), Some("around") | Some("space-around") => el = el.justify_around(), + Some("evenly") | Some("space-evenly") => el = el.justify_evenly(), _ => {} } match style.align_self.as_deref() { @@ -5898,6 +5901,11 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc) -> E { if let Some(left) = style.left { el = el.left(gpui::px(left as f32)); } + if let Some(ratio) = style.aspect_ratio { + if ratio > 0.0 && ratio.is_finite() { + el = el.aspect_ratio(ratio as f32); + } + } if let Some(background) = style.resolved_background() { el = el.bg(background); } @@ -5988,16 +5996,31 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc) -> E { el = el.border_color(color); } } - if let Some(ref shadow) = style.box_shadow { - if let Some(color) = crate::color::parse_color_rgba(&shadow.color) { - let shadow = gpui::BoxShadow::new( - gpui::px(shadow.offset_x as f32), - gpui::px(shadow.offset_y as f32), - color.into(), - ) - .blur_radius(gpui::px(shadow.blur_radius.max(0.0) as f32)) - .spread_radius(gpui::px(shadow.spread_radius as f32)); - el = el.shadow(vec![shadow]); + match style.border_style.as_deref() { + Some("dashed") => el = el.border_dashed(), + Some("solid") => el.style().border_style = Some(gpui::BorderStyle::Solid), + _ => {} + } + if let Some(ref shadows) = style.box_shadow { + // A shadow whose colour does not parse is dropped on its own, so one + // bad entry does not take the rest of a list with it. + let shadows: Vec = shadows + .as_slice() + .iter() + .filter_map(|shadow| { + let color = crate::color::parse_color_rgba(&shadow.color)?; + let built = gpui::BoxShadow::new( + gpui::px(shadow.offset_x as f32), + gpui::px(shadow.offset_y as f32), + color.into(), + ) + .blur_radius(gpui::px(shadow.blur_radius.max(0.0) as f32)) + .spread_radius(gpui::px(shadow.spread_radius as f32)); + Some(if shadow.inset { built.inset() } else { built }) + }) + .collect(); + if !shadows.is_empty() { + el = el.shadow(shadows); } } if style.outline_width.is_some() || style.outline_color.is_some() { diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index e564b8281..8c1745778 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -17,6 +17,26 @@ pub struct BoxShadowValue { pub blur_radius: f64, pub spread_radius: f64, pub color: String, + /// Draws inside the border box instead of around it, like CSS `inset`. + #[serde(default)] + pub inset: bool, +} + +/// `boxShadow` is one shadow or a list of them, painted in the order given. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum BoxShadowValues { + Many(Vec), + One(BoxShadowValue), +} + +impl BoxShadowValues { + pub fn as_slice(&self) -> &[BoxShadowValue] { + match self { + Self::Many(shadows) => shadows, + Self::One(shadow) => std::slice::from_ref(shadow), + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -197,7 +217,11 @@ pub struct StyleDesc { pub border_top_right_radius: Option, pub border_bottom_left_radius: Option, pub border_bottom_right_radius: Option, - pub box_shadow: Option, + pub box_shadow: Option, + /// `"solid"` or `"dashed"`, for every side. + pub border_style: Option, + /// Width divided by height, applied to whichever axis is not definite. + pub aspect_ratio: Option, /// CSS `outline`: a line outside the border box that takes no layout /// space, so it can appear on focus without moving anything. pub outline_width: Option, @@ -415,6 +439,32 @@ mod tests { assert!(!should_occlude(&style)); } + #[test] + fn box_shadow_takes_one_shadow_or_a_list() { + let one: StyleDesc = serde_json::from_str( + r##"{"boxShadow":{"offsetX":0,"offsetY":4,"blurRadius":12,"spreadRadius":0,"color":"#00000033"}}"##, + ) + .unwrap(); + let shadows = one.box_shadow.as_ref().unwrap().as_slice(); + assert_eq!(shadows.len(), 1); + assert!( + !shadows[0].inset, + "a shadow is outside unless it says otherwise" + ); + + let many: StyleDesc = serde_json::from_str( + r##"{"boxShadow":[ + {"offsetX":0,"offsetY":1,"blurRadius":2,"spreadRadius":0,"color":"#0003"}, + {"offsetX":0,"offsetY":0,"blurRadius":0,"spreadRadius":1,"color":"#fff1","inset":true} + ]}"##, + ) + .unwrap(); + let shadows = many.box_shadow.as_ref().unwrap().as_slice(); + assert_eq!(shadows.len(), 2); + assert!(shadows[1].inset); + assert_eq!(shadows[1].spread_radius, 1.0); + } + #[test] fn maps_the_timeline_cursors() { assert_eq!( diff --git a/packages/react/src/__tests__/styles-css.test.tsx b/packages/react/src/__tests__/styles-css.test.tsx new file mode 100644 index 000000000..882528843 --- /dev/null +++ b/packages/react/src/__tests__/styles-css.test.tsx @@ -0,0 +1,184 @@ +import fs from "fs" +import { describe, expect, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" +import { expectScreenshotsDiffer, SHOTS_DIR } from "./test-utils.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +type TestRenderer = ReturnType["renderer"] + +function rect(renderer: TestRenderer, testId: string) { + const element = renderer.findByTestId(testId) + expect(element, `missing testId ${testId}`).toBeDefined() + const bounds = renderer.getElementBounds(element!.id) + expect(bounds, `no painted bounds for ${testId}`).toBeTruthy() + return bounds! +} + +function screenshot(renderer: TestRenderer, name: string): string { + const path = `${SHOTS_DIR}/gpuix-css-${name}.png` + if (fs.existsSync(path)) fs.unlinkSync(path) + renderer.captureScreenshot(path) + return path +} + +describeNative("aspectRatio", () => { + it("derives the height from the width", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(
) + expect(rect(renderer, "box")).toMatchObject({ width: 200, height: 100 }) + } finally { + unmount() + } + }) + + it("derives the width from the height", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(
) + expect(rect(renderer, "box")).toMatchObject({ width: 180, height: 60 }) + } finally { + unmount() + } + }) + + it("ignores a ratio that is not positive", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(
) + expect(rect(renderer, "box")).toMatchObject({ width: 200, height: 50 }) + } finally { + unmount() + } + }) +}) + +describeNative("justifyContent: space-evenly", () => { + it("puts equal space before, between and after the children", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render( +
+ {["a", "b", "c"].map((id) => ( +
+ ))} +
+ ) + // 300 - 90 = 210 free, in 4 equal gaps of 52.5. Layout rounds to whole pixels. + const [a, b, c] = ["a", "b", "c"].map((id) => rect(renderer, id).x) as [number, number, number] + expect(Math.abs(a - 52.5)).toBeLessThanOrEqual(1) + expect(Math.abs(b - a - 82.5)).toBeLessThanOrEqual(1) + expect(Math.abs(c - b - 82.5)).toBeLessThanOrEqual(1) + } finally { + unmount() + } + }) +}) + +describeNative("alignItems", () => { + const row = (alignItems: string) => ( +
+
+
+ ) + + it("stretch fills the cross axis, which center does not", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(row("stretch")) + expect(rect(renderer, "child").height).toBe(100) + render(row("center")) + expect(rect(renderer, "child").height).toBeLessThan(100) + } finally { + unmount() + } + }) +}) + +describeNative("alignItems: baseline", () => { + it("lines up the text of different sizes on one baseline", () => { + const { render, renderer, unmount } = createTestRoot() + try { + const row = (alignItems: string) => ( +
+
+ Big +
+
+ small +
+
+ ) + render(row("start")) + expect(rect(renderer, "small").y).toBe(rect(renderer, "big").y) + + render(row("baseline")) + const big = rect(renderer, "big") + const small = rect(renderer, "small") + // The small text drops so that its bottom edge meets the big text's. + expect(small.y).toBeGreaterThan(big.y) + expect(Math.abs(small.y + small.height - (big.y + big.height))).toBeLessThanOrEqual(2) + } finally { + unmount() + } + }) +}) + +describeNative("borderStyle and boxShadow", () => { + const frame = (style: object) => ( +
+
+
+ ) + const shadow = { offsetX: 0, offsetY: 6, blurRadius: 12, spreadRadius: 0, color: "#000000cc" } + + it("draws a dashed border differently from a solid one", () => { + const { render, renderer, unmount } = createTestRoot() + try { + const border = { borderWidth: 3, borderColor: "#89b4fa" } + render(frame({ ...border, borderStyle: "solid" })) + const solid = screenshot(renderer, "border-solid") + render(frame({ ...border, borderStyle: "dashed" })) + expectScreenshotsDiffer(solid, screenshot(renderer, "border-dashed")) + } finally { + unmount() + } + }) + + it("paints every shadow in a list", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(frame({ boxShadow: shadow })) + const one = screenshot(renderer, "shadow-one") + render(frame({ boxShadow: [shadow, { ...shadow, offsetY: -12, color: "#f38ba8cc" }] })) + expectScreenshotsDiffer(one, screenshot(renderer, "shadow-two")) + } finally { + unmount() + } + }) + + it("paints an inset shadow differently from an outer one", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(frame({ boxShadow: shadow })) + const outer = screenshot(renderer, "shadow-outer") + render(frame({ boxShadow: { ...shadow, inset: true } })) + expectScreenshotsDiffer(outer, screenshot(renderer, "shadow-inset")) + } finally { + unmount() + } + }) + + it("keeps a valid shadow when another one in the list has a bad colour", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(frame({})) + const none = screenshot(renderer, "shadow-none") + render(frame({ boxShadow: [{ ...shadow, color: "not-a-colour" }, shadow] })) + expectScreenshotsDiffer(none, screenshot(renderer, "shadow-partly-bad")) + } finally { + unmount() + } + }) +}) From f10a6c1b35629c5deb81b8a50d20fade49d593d4 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:49:07 +0100 Subject: [PATCH 09/27] test(examples): run perf budgets against a production React and add a chat profiler --- examples/chat.perf.test.tsx | 8 ++++ examples/package.json | 2 +- examples/profile-chat.tsx | 86 +++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 examples/profile-chat.tsx diff --git a/examples/chat.perf.test.tsx b/examples/chat.perf.test.tsx index 61842e509..f7376d62f 100644 --- a/examples/chat.perf.test.tsx +++ b/examples/chat.perf.test.tsx @@ -7,6 +7,12 @@ * * THROTTLE=utility|background|maintenance re-execs under taskpolicy -c. * A throttled run logs numbers and skips the default budgets. + * + * Run it through `bun run test:perf`: that sets NODE_ENV=production, so React + * is the build apps ship. React's development build measures and diffs every + * component it renders for its DevTools tracks, which more than doubles a + * commit here and says nothing about what users get. Files run one at a time, + * so two renderers do not compete for the same cores. */ import React from 'react' @@ -188,6 +194,8 @@ describeNative('chat performance', () => { // First query: collects and folds the subtree once. Not measured. render(app('p')) + // So are a few more, which the engine needs to compile the commit path. + for (let i = 0; i < WARMUP; i++) render(app(i % 2 === 0 ? 'pe' : 'per')) const word = 'performance of the renderer' const keystrokes: number[] = [] diff --git a/examples/package.json b/examples/package.json index 4896a652e..eba48d22b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,7 +7,7 @@ "counter": "bun --hot counter.tsx", "diff": "bun --hot diff.tsx", "test": "vitest run --exclude '**/*.perf.test.tsx'", - "test:perf": "vitest run chat.perf.test.tsx timeline.perf.test.tsx rerender.perf.test.tsx", + "test:perf": "NODE_ENV=production vitest run --no-file-parallelism chat.perf.test.tsx timeline.perf.test.tsx rerender.perf.test.tsx", "native-text": "bun --hot native-text.tsx", "chat": "bun --hot chat.tsx", "chat-solid": "bun --hot solid/chat.tsx", diff --git a/examples/profile-chat.tsx b/examples/profile-chat.tsx new file mode 100644 index 000000000..8ac41b289 --- /dev/null +++ b/examples/profile-chat.tsx @@ -0,0 +1,86 @@ +/** + * Loops one chat interaction so a sampling profiler has something to look at. + * + * MODE=highlight bun profile-chat.tsx & + * sample $! 6 -file /tmp/chat.txt + * + * MODE=highlight commit + frame for each query of a find-bar keystroke run + * MODE=frame the native frame alone, with nothing changed + * MODE=commit React's commit alone for each query + * MODE=mount mounts the 1000-turn chat from scratch, over and over + * MODE=sidebar clicks the sidebar toggle through the automation client + * + * Wall time is noisy on a busy machine, so every mode also reports the CPU time + * the process spent per iteration, which load on other processes does not move. + */ + +import React from 'react' +import { flushSync } from '@gpuix/react' +import { createTestRoot } from '@gpuix/react/testing' +import { connectTest } from '@gpuix/react/automation' +import { ChatApp } from './chat' + +const mode = process.env.MODE ?? 'highlight' +const seconds = Number(process.env.SECONDS ?? '12') +const TURNS = 1_000 +const word = 'performance of the renderer' + +const app = (query: string, activeIndex = 0) => ( +
+ +
+) + +function cpuMs() { + const usage = process.cpuUsage() + return (usage.user + usage.system) / 1000 +} + +let root = createTestRoot() +root.render(app('')) +for (let i = 0; i < 10; i++) root.renderer.flush() +root.render(app('p')) + +const automation = mode === 'sidebar' ? await connectTest(root.renderer) : undefined +if (automation) { + await automation.getByTestId('sidebar-collapse').waitFor() + await automation.clock.pause() +} + +const end = performance.now() + seconds * 1000 +let iterations = 0 +let wall = 0 +let cpu = 0 +let step = 2 +while (performance.now() < end) { + const wallStart = performance.now() + const cpuStart = cpuMs() + if (automation) { + const testId = iterations % 2 === 0 ? 'sidebar-collapse' : 'sidebar-expand' + await automation.getByTestId(testId).click() + await automation.clock.fastForward(200) + } else if (mode === 'mount') { + root.unmount() + root = createTestRoot() + root.render(app('')) + } else if (mode === 'frame') { + root.renderer.flush() + } else { + const query = word.slice(0, step) + step = step >= word.length ? 2 : step + 1 + if (mode === 'commit') flushSync(() => root.root.render(app(query))) + else root.render(app(query)) + } + wall += performance.now() - wallStart + cpu += cpuMs() - cpuStart + iterations++ +} +console.log( + JSON.stringify({ + mode, + iterations, + wallMs: +(wall / iterations).toFixed(2), + cpuMs: +(cpu / iterations).toFixed(2), + }), +) +process.exit(0) From f3eede6b3e0e9865d7ca9ce5ad3e943f7f49225b Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:58:28 +0100 Subject: [PATCH 10/27] feat(native): registerFont loads a font that fontFamily can name --- .changeset/register-font.md | 7 ++ README.md | 31 +++++++ packages/native/Cargo.lock | 1 + packages/native/Cargo.toml | 2 + packages/native/index.d.ts | 9 ++ packages/native/js/host.ts | 1 + packages/native/js/testing.ts | 7 ++ packages/native/src/renderer.rs | 84 ++++++++++++++++++ packages/native/src/test_renderer.rs | 14 +++ .../fixtures/gpuix-test-nameless.ttf | Bin 0 -> 748 bytes .../__tests__/fixtures/gpuix-test-square.ttf | Bin 0 -> 1008 bytes .../__tests__/fixtures/make-square-font.py | 52 +++++++++++ packages/react/src/__tests__/fonts.test.tsx | 84 ++++++++++++++++++ zed | 2 +- 14 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 .changeset/register-font.md create mode 100644 packages/react/src/__tests__/fixtures/gpuix-test-nameless.ttf create mode 100644 packages/react/src/__tests__/fixtures/gpuix-test-square.ttf create mode 100644 packages/react/src/__tests__/fixtures/make-square-font.py create mode 100644 packages/react/src/__tests__/fonts.test.tsx diff --git a/.changeset/register-font.md b/.changeset/register-font.md new file mode 100644 index 000000000..18b45e5b7 --- /dev/null +++ b/.changeset/register-font.md @@ -0,0 +1,7 @@ +--- +'@gpuix/native': minor +'@gpuix/react': minor +'@gpuix/solid': minor +--- + +`registerFont(bytes)` loads a TrueType or OpenType font so `fontFamily` can name it. Text that asked for the family before it was registered picks the font up on the next frame. Fonts GPUI would abort on, or cannot use, are rejected with an error instead. diff --git a/README.md b/README.md index be1e05eb0..469dce21c 100644 --- a/README.md +++ b/README.md @@ -830,6 +830,37 @@ activation policy, so there is no menu-bar-agent mode yet. For a real background daemon, run the app from a `launchd` agent in `~/Library/LaunchAgents/`; launchd never activates the process. +### Custom fonts + +`registerFont()` makes a font that is not installed on the machine available to +`fontFamily`. It takes the bytes of a **TrueType or OpenType** file (`.ttf` or +`.otf`). + +```tsx +import { readFileSync } from 'node:fs' +import { useGpuixRequired } from '@gpuix/react' + +function LoadFonts() { + const renderer = useGpuixRequired() + useEffect(() => { + renderer.registerFont?.(readFileSync('./Inter-Regular.ttf')) + }, [renderer]) + return
Hello
+} +``` + +Text that already named the family repaints with the font once it is +registered, so there is no need to register before the first render. Register +every weight and style of a family you use; text picks the closest one. + +The font must have family, full and PostScript names and a glyph for `"m"`. +GPUI aborts the process on a font without the names, and silently drops a font +without the glyph, so `registerFont()` throws for both and for bytes that are +not a font. Fonts load into the process and last until it exits. + +WOFF and WOFF2 are not supported; convert them to TrueType first. The browser +renderer does not have this method, use `FontFace` there. + ### Let an agent drive the app Make focus opt-in through the environment, so a human run behaves normally and diff --git a/packages/native/Cargo.lock b/packages/native/Cargo.lock index 916fc1f32..0015c1716 100644 --- a/packages/native/Cargo.lock +++ b/packages/native/Cargo.lock @@ -2622,6 +2622,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "ttf-parser", "two-face", "unicode-segmentation", "url", diff --git a/packages/native/Cargo.toml b/packages/native/Cargo.toml index d3332cdd3..666c39d49 100644 --- a/packages/native/Cargo.toml +++ b/packages/native/Cargo.toml @@ -31,6 +31,8 @@ thiserror = "2" pulldown-cmark = { version = "0.12", default-features = false } unicode-segmentation = "1" web-time = "1" +# Checks fonts handed to `registerFont`; GPUI's text system aborts on malformed ones. +ttf-parser = { version = "0.25", default-features = false, features = ["std"] } # Syntect compiles a grammar's TextMate regexes lazily, on the FIRST highlight # of that language, on the frame thread, inside a paint. fancy-regex takes diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 703b58e2b..e87492687 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -162,6 +162,13 @@ export declare class GpuixRenderer { * an `` host node. Prefer `setImagePixels` for live waveforms. */ setImage(elementId: number, bytes: Buffer): void + /** + * Make the fonts in a TrueType or OpenType file (or a collection) usable by + * `fontFamily`, under the family names the file declares. Call it before + * the text that names the family is first laid out: a family that did not + * exist then has already fallen back to another font. + */ + registerFont(data: Buffer): void getAutomationTree(): string getElementBounds(id: number): ElementBounds | null getAllText(): Array @@ -356,6 +363,8 @@ export declare class TestGpuixRenderer { setImagePixels(elementId: number, width: number, height: number, pixels: Buffer, format?: string | undefined | null): void /** Encoded image bytes onto an `` host node. */ setImage(elementId: number, bytes: Buffer): void + /** Make the fonts in a TrueType or OpenType file usable by `fontFamily`. */ + registerFont(data: Buffer): void /** * The logical scroll anchor of a ``: * `[itemIndex, offsetInItemPx, viewportHeightPx]`, or null for anything diff --git a/packages/native/js/host.ts b/packages/native/js/host.ts index f813d2bdd..9585d9313 100644 --- a/packages/native/js/host.ts +++ b/packages/native/js/host.ts @@ -675,6 +675,7 @@ export type NativeRenderer = MutationHost & | "getWindowSize" | "getWindowInsets" | "setWindowTitle" + | "registerFont" | "promptForPaths" | "activateWindow" | "minimizeWindow" diff --git a/packages/native/js/testing.ts b/packages/native/js/testing.ts index 0c1ae5635..ecbb5a578 100644 --- a/packages/native/js/testing.ts +++ b/packages/native/js/testing.ts @@ -81,6 +81,8 @@ interface NativeTestRendererApi extends NativeRenderer { scrollToItem(elementId: number, index: number, offsetInItem?: number): void scrollIntoView(elementId: number): void setImage(elementId: number, bytes: Buffer | Uint8Array): void + /** Make a TrueType or OpenType font usable by `fontFamily`. */ + registerFont(bytes: Buffer | Uint8Array): void setImagePixels( elementId: number, width: number, @@ -567,6 +569,11 @@ export class TestRenderer implements NativeRenderer { this.native.flush() } + registerFont(bytes: Buffer | Uint8Array): void { + this.native.registerFont(bytes as Buffer) + this.native.flush() + } + setImagePixels( elementId: number, width: number, diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 442054b76..3a788a72c 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -439,6 +439,10 @@ enum UiCommand { bytes: Vec, response: SyncSender>, }, + RegisterFont { + bytes: Vec, + response: SyncSender>, + }, GetScrollOffset { id: u64, response: SyncSender>, @@ -632,6 +636,11 @@ async fn run_ui_commands( .send(view.set_encoded_image(id, bytes, window, cx)) .ok(); }), + UiCommand::RegisterFont { bytes, response } => { + window.update(cx, move |view, window, cx| { + response.send(view.register_font(bytes, window, cx)).ok(); + }) + } UiCommand::GetScrollOffset { id, response } => { let offset = VIRTUAL_LIST_STATES .with(|cell| { @@ -2254,6 +2263,36 @@ impl GpuixRenderer { Err(Error::from_reason("Unsupported operating system")) } + /// Make the fonts in a TrueType or OpenType file (or a collection) usable by + /// `fontFamily`, under the family names the file declares. Call it before + /// the text that names the family is first laid out: a family that did not + /// exist then has already fallen back to another font. + #[napi] + pub fn register_font(&self, data: Buffer) -> Result<()> { + let bytes = data.to_vec(); + #[cfg(target_os = "macos")] + { + return update_window(move |view, window, cx| view.register_font(bytes, window, cx))? + .map_err(Error::from_reason); + } + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + { + let (response, receiver) = sync_channel(1); + self.send_ui_command(UiCommand::RegisterFont { bytes, response })?; + return recv_ui_response(receiver, "the font registration")? + .map_err(Error::from_reason); + } + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + Err(Error::from_reason("Unsupported operating system")) + } + #[napi] pub fn get_automation_tree(&self) -> Result { self.request_invalidate()?; @@ -3796,6 +3835,24 @@ impl GpuixView { self.set_live_image(id, image, window, cx) } + /// Adds the fonts in `bytes` to the text system and repaints, so text that + /// names one of them picks it up. + pub(crate) fn register_font( + &mut self, + bytes: Vec, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> std::result::Result<(), String> { + check_font(&bytes)?; + window + .text_system() + .add_fonts(vec![std::borrow::Cow::Owned(bytes)]) + .map_err(|error| error.to_string())?; + cx.notify(); + window.refresh(); + Ok(()) + } + pub(crate) fn scroll_element_into_view(&self, id: u64) -> bool { let Some((scroller_id, child_index)) = self.scroll_target(id) else { return false; @@ -7610,3 +7667,30 @@ mod window_options_tests { } } } + +/// Rejects fonts GPUI's text system cannot load. It unwraps the family, full and +/// PostScript name records and aborts the whole process when one is missing, and +/// skips a font without an "m" glyph, which would leave the family unresolvable. +fn check_font(bytes: &[u8]) -> std::result::Result<(), String> { + use ttf_parser::name_id::{FAMILY, FULL_NAME, POST_SCRIPT_NAME}; + + let face = ttf_parser::Face::parse(bytes, 0) + .map_err(|error| format!("not a TrueType or OpenType font: {error}"))?; + for (id, what) in [ + (FAMILY, "family"), + (FULL_NAME, "full"), + (POST_SCRIPT_NAME, "PostScript"), + ] { + let named = face + .names() + .into_iter() + .any(|name| name.name_id == id && name.to_string().is_some()); + if !named { + return Err(format!("font has no {what} name")); + } + } + if face.glyph_index('m').is_none() { + return Err("font has no glyph for \"m\"".to_string()); + } + Ok(()) +} diff --git a/packages/native/src/test_renderer.rs b/packages/native/src/test_renderer.rs index e1cffc202..1f8383b4f 100644 --- a/packages/native/src/test_renderer.rs +++ b/packages/native/src/test_renderer.rs @@ -884,6 +884,20 @@ impl TestGpuixRenderer { }) } + /// Make the fonts in a TrueType or OpenType file usable by `fontFamily`. + #[napi] + pub fn register_font(&self, data: Buffer) -> Result<()> { + let bytes = data.to_vec(); + with_test_state(|cx, window, view| { + let view = view.clone(); + cx.update_window(window, |_, window, app| { + view.update(app, |view, cx| view.register_font(bytes, window, cx)) + }) + .map_err(|e| Error::from_reason(e.to_string()))? + .map_err(Error::from_reason) + }) + } + /// The logical scroll anchor of a ``: /// `[itemIndex, offsetInItemPx, viewportHeightPx]`, or null for anything /// else. `itemIndex == item count` is gpui's at-end sentinel. diff --git a/packages/react/src/__tests__/fixtures/gpuix-test-nameless.ttf b/packages/react/src/__tests__/fixtures/gpuix-test-nameless.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a30d67dcc774ed654af03072eade563db610c89c GIT binary patch literal 748 zcmah{Jxjw-6g_ETYpbZm4xO}0h2r7|QcwzhfV7KMYIhAawuPh(O$${)baN1N5_d=a z18$w1JLu#hBGS>tO^D|u6(T~t;k|p$x%b_7^4^04AcPahh;44q%&*5XM}U+dyIU|+ z6St@nUlXT_`oaFq`E!f-MdVAGy0`FfddFCdWQoMUec*}l8%Mlk)SIJcj39}5M6VW9 zWH3woNE|ZMrU^ekNd3gIifU+;;00@aE5yfU)vDtVQ^YOeX$Y+x8_Ds-o#E6cWVu5I z?;m3|uMe~xUWQMwG_G+g18o60i%wos97cibxuG}d#Io`#U80W_|J$$b%QP-oh2d&& z=U+`?u0D|w3cD3TgUk`(EILNGcY@yUsPthChHVqGvu)cg`^xiIgNPz7PTJ+<4tuiK zTzDcZ;;(3L-Z{Z;y@-3%gKCo9oh5{ScH{{Lh^Sl%GCN5k;$%|Y6ds8GmChjZFH?#$O1E9lg AYybcN literal 0 HcmV?d00001 diff --git a/packages/react/src/__tests__/fixtures/gpuix-test-square.ttf b/packages/react/src/__tests__/fixtures/gpuix-test-square.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5f716cf9a045aa8fa291e17d7347e7e6e076f6df GIT binary patch literal 1008 zcmb7Dze^lJ6#n*R?=JozMwDoEY~Tb5obf`m5|W5PJH->*xR}k|!tLFV(SN|sB3N6PSO_8#f@PX?fz9`JM_5g`khi><@B8NMdv9iTVF2jH78I=c(vzpJ zthr6V7^S+h>e)W_(Io#!K2@%+zx}jxenfsL>lMd-^Xz#0fLMI8LZRm{`-S)$Jzw#f z?Sc11Fl2mCt*_dc!#MdVdADb`ePsB+xJPc)Y|lwgxD!C?1+{m6z0t%5MgaAh>yIJ5 z(sLK?*VlbhKcUn$6@34Z3zBp8B+?a9qSE+I9GN{4%26^U&5RTi@yrI2ixF1lm9dL7 zBmQl-vOlNsm6!119^&y<5-aXcR-{86Aw4MiAVHFeFL%vJ1v)GzqgaA!R_K^-r+Lp;!no5}|IZYuHwvt;o}C=B7D#wKw= z<1~6Oqj4sR;ZDsiDml!{u8C8)4^Lx-F>GpV67Ol8X7)3UGZHiFx7_xU(`c5~T6W+p zI^|Z?4(_Ohzsr`Emz|*D)@xRAY#g)j(SnOMmf)blU!sIHDmDW0MS7X68u8Ath5w4S i?nE!MQo#By>Zrj&k(=cu|M|7@8WJYoY@Ywo0`ME_- fs.readFileSync(new URL(`./fixtures/${name}`, import.meta.url)) + +// "A" is one em wide and "B" two, both drawn as squares, so "AB" at 20px is 60 +// wide in this font (layout rounds to a whole pixel) and nowhere near that in +// the system fonts it falls back to. +const SQUARE_FONT = fixture("gpuix-test-square.ttf") + +const row = (family: string) => ( +
+
+ AB +
+
+) + +type TestRenderer = ReturnType["renderer"] + +function expectSquareWidth(renderer: TestRenderer) { + expect(Math.abs(wordWidth(renderer) - 60)).toBeLessThanOrEqual(1) +} + +function wordWidth(renderer: TestRenderer) { + const word = renderer.findByTestId("word") + expect(word).toBeDefined() + return renderer.getElementBounds(word!.id)!.width +} + +describeNative("registerFont", () => { + it("makes a font that is not installed available to fontFamily", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(row("GpuixTestSquare")) + expect( + Math.abs(wordWidth(renderer) - 60), + "an unregistered family falls back to another font", + ).toBeGreaterThan(5) + + renderer.registerFont(SQUARE_FONT) + expectSquareWidth(renderer) + } finally { + unmount() + } + }) + + it("uses a font registered before any text asks for it", () => { + const { render, renderer, unmount } = createTestRoot() + try { + renderer.registerFont(SQUARE_FONT) + render(row("GpuixTestSquare")) + expectSquareWidth(renderer) + } finally { + unmount() + } + }) + + it("rejects bytes that are not a font and keeps working", () => { + const { render, renderer, unmount } = createTestRoot() + try { + expect(() => renderer.registerFont(new Uint8Array([1, 2, 3, 4]))).toThrow(/not a TrueType/) + render(row("GpuixTestSquare")) + renderer.registerFont(SQUARE_FONT) + expectSquareWidth(renderer) + } finally { + unmount() + } + }) + + it("rejects a font GPUI would abort on instead of crashing the process", () => { + const { render, renderer, unmount } = createTestRoot() + try { + render(row("GpuixTestNameless")) + expect(() => renderer.registerFont(fixture("gpuix-test-nameless.ttf"))).toThrow(/full name/) + expect(wordWidth(renderer)).toBeGreaterThan(0) + } finally { + unmount() + } + }) +}) diff --git a/zed b/zed index ea042f2f0..7bc43009e 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit ea042f2f045157ada1d0ad71009520931dcace83 +Subproject commit 7bc43009e89f37e1ce765d0c2aa98e3f5c64cbeb From c7dd862ad1c2309d79f7fe5cd6c1f64022c60ae0 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:06:24 +0100 Subject: [PATCH 11/27] feat(native): onLayout reports an element's size when it changes --- .changeset/on-layout.md | 9 + README.md | 17 ++ packages/native/index.d.ts | 6 + packages/native/js/host.ts | 8 + packages/native/js/mutations.ts | 1 + packages/native/src/automation.rs | 43 ++-- packages/native/src/custom_elements/mod.rs | 8 +- packages/native/src/element_tree.rs | 7 + packages/native/src/renderer.rs | 76 ++++++- .../react/src/__tests__/layout-event.test.tsx | 188 ++++++++++++++++++ .../solid/src/__tests__/renderer.test.tsx | 13 ++ 11 files changed, 348 insertions(+), 28 deletions(-) create mode 100644 .changeset/on-layout.md create mode 100644 packages/react/src/__tests__/layout-event.test.tsx diff --git a/.changeset/on-layout.md b/.changeset/on-layout.md new file mode 100644 index 000000000..b465419d0 --- /dev/null +++ b/.changeset/on-layout.md @@ -0,0 +1,9 @@ +--- +'@gpuix/native': minor +'@gpuix/react': minor +'@gpuix/solid': minor +--- + +`onLayout` on `
` and `` reports the element's border box after its first paint and each time its size changes, with no polling of `getElementBounds`. The event payload gains `width` and `height`. + +`visibility: "hidden"` elements have never reported bounds, because gpui paints nothing for them and nothing inside them. The canvas fallback that claimed otherwise could not run and is gone. diff --git a/README.md b/README.md index 469dce21c..7188ddfee 100644 --- a/README.md +++ b/README.md @@ -2991,6 +2991,7 @@ text imports no longer need a runtime flag. | Focus | `onFocus` | — | | Blur | `onBlur` | — | | Scroll | `onScroll` | `deltaX`, `deltaY`, `precise`, `touchPhase`, `modifiers` | +| Layout | `onLayout` | `width`, `height`, `x`, `y` — `
` and `` only | | File drop | `onFileDrop` | `paths`, `x`, `y` — Unicode filesystem paths from Finder or the OS | | Change | `onChange` | `value` — `` and `