From 539a126119baf06e0b45ffbbfeda0fdd13d7dd98 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 20:15:26 +0100 Subject: [PATCH 1/7] perf(native): scroll corrections keep a fling, measurements batch per frame - Anchor corrections in the message list use element.scrollBy; the native bridge sends them as a relative shift that Rune's new Tx::scroll_by applies from wherever the viewport is, keeping a finger's drag and a fling's momentum (scroll_to_instant stopped both, and a stale absolute offset snapped the list back). - The bridge's ResizeObserver delivers a batch of host layouts in one callback, as a browser's does per frame: one MeasuredRows per frame. - iOS: a batch of host lines crosses to the worker as one message. - Rune pinned to Makisuo/rune hazel-scroll-by (c3536c2). Co-Authored-By: Claude Opus 5.5 --- apps/native/host/Cargo.lock | 10 +++--- apps/native/host/Cargo.toml | 7 ++-- apps/native/host/ios/Cargo.toml | 2 +- apps/native/host/ios/src/page.rs | 11 +++--- apps/native/host/src/capture.rs | 11 +++--- apps/native/host/src/render/handlers.rs | 15 ++++++++ apps/native/host/src/render/mod.rs | 2 +- apps/native/host/src/shell.rs | 4 +-- apps/native/host/src/wire.rs | 5 +++ apps/native/src/ios.ts | 10 ++++-- apps/web-foldkit/src/mount/message-list.ts | 6 ++-- packages/foldkit-rune/src/bridge.test.ts | 41 +++++++++++++++++++++- packages/foldkit-rune/src/bridge.ts | 36 ++++++++++++++----- packages/foldkit-rune/src/wire.ts | 6 ++++ 14 files changed, 129 insertions(+), 37 deletions(-) diff --git a/apps/native/host/Cargo.lock b/apps/native/host/Cargo.lock index 7722eefbe..53bfa1eef 100644 --- a/apps/native/host/Cargo.lock +++ b/apps/native/host/Cargo.lock @@ -2905,7 +2905,7 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rune" version = "0.0.1" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" dependencies = [ "accesskit", "accesskit_macos", @@ -2982,7 +2982,7 @@ dependencies = [ [[package]] name = "rune-fx" version = "0.0.1" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" dependencies = [ "bytemuck", "rune-motion", @@ -2993,12 +2993,12 @@ dependencies = [ [[package]] name = "rune-motion" version = "0.1.0" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" [[package]] name = "rune-staging" version = "0.0.1" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" dependencies = [ "wgpu", ] @@ -3006,7 +3006,7 @@ dependencies = [ [[package]] name = "rune-taffy" version = "0.0.1" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" dependencies = [ "taffy", ] diff --git a/apps/native/host/Cargo.toml b/apps/native/host/Cargo.toml index 4e48666f0..31bfde7f4 100644 --- a/apps/native/host/Cargo.toml +++ b/apps/native/host/Cargo.toml @@ -13,11 +13,12 @@ publish = false # El::focusable_by_script and El::clip_fade_linear_frac (Makisuo/rune's `hazel-grid` branch), plus # iOS's full-screen window, safe area and motion frames (`hazel-ios`), plus a soft keyboard's # return as Enter, iOS's display link and El::pin_insets (`hazel-ios-followups`), plus a -# long-press opening an element's context menu (`hazel-long-press`), until they land upstream. -rune = { git = "https://github.com/Makisuo/rune", rev = "3b676200f9ab1923059651c4e81c0b77cb8699a2", features = [ +# long-press opening an element's context menu (`hazel-long-press`), plus Tx::scroll_by (`hazel-scroll-by`), +# until they land upstream. +rune = { git = "https://github.com/Makisuo/rune", rev = "c3536c225a3a7e0bb8af798e011ef56fd4a8bb92", features = [ "native-file-dialog", ] } -rune-fx = { git = "https://github.com/Makisuo/rune", rev = "3b676200f9ab1923059651c4e81c0b77cb8699a2" } +rune-fx = { git = "https://github.com/Makisuo/rune", rev = "c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" } base64 = "0.22" # Decodes a picture and its mask picture to composite them (Rune's own decoder, same features). image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } diff --git a/apps/native/host/ios/Cargo.toml b/apps/native/host/ios/Cargo.toml index ec44ba619..d89040590 100644 --- a/apps/native/host/ios/Cargo.toml +++ b/apps/native/host/ios/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] hazel-native = { path = ".." } # The same revision as `hazel-native`'s (`../Cargo.toml`). -rune = { git = "https://github.com/Makisuo/rune", rev = "3b676200f9ab1923059651c4e81c0b77cb8699a2" } +rune = { git = "https://github.com/Makisuo/rune", rev = "c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" } serde_json = "1" # The hidden web view the runtime's worker runs in (`src/bridge.rs`), and the UIKit views of diff --git a/apps/native/host/ios/src/page.rs b/apps/native/host/ios/src/page.rs index 256696f11..43719ce1c 100644 --- a/apps/native/host/ios/src/page.rs +++ b/apps/native/host/ios/src/page.rs @@ -95,14 +95,15 @@ window.__hazel = {{ worker.onmessage = (event) => host.postMessage(event.data) worker.onerror = (event) => host.postMessage({{ log: `[worker] ${{event.message}} ${{event.filename}}:${{event.lineno}}` }}) - for (const line of early.splice(0)) worker.postMessage(line) + for (const batch of early.splice(0)) worker.postMessage(batch) }}, reply(message) {{ worker?.postMessage(message) }}, + // A frame's lines cross in one message, as they do down the desktop's pipe: the worker + // splits them, and handles a frame's reports in one task. toWorker(text) {{ - for (const line of text.split("\n")) - if (line !== "") worker === undefined ? early.push(line) : worker.postMessage(line) + if (text !== "") worker === undefined ? early.push(text) : worker.postMessage(text) }}, }} host.postMessage({{ ready: {id} }}) @@ -215,8 +216,8 @@ mod tests { assert!(ready < start); // Lines that arrive before the worker exists wait for it, in order. assert!( - page.contains("worker === undefined ? early.push(line) : worker.postMessage(line)") + page.contains("worker === undefined ? early.push(text) : worker.postMessage(text)") ); - assert!(page.contains("for (const line of early.splice(0)) worker.postMessage(line)")); + assert!(page.contains("for (const batch of early.splice(0)) worker.postMessage(batch)")); } } diff --git a/apps/native/host/src/capture.rs b/apps/native/host/src/capture.rs index 503a5f9c5..8a4d8c8b3 100644 --- a/apps/native/host/src/capture.rs +++ b/apps/native/host/src/capture.rs @@ -12,7 +12,8 @@ use serde_json::json; use hazel_native::wire::{Node, RuntimeMsg}; use hazel_native::{ - PRESS_EVENTS, State, TEXT_SMOOTHING, images, note_scroll, outbox, view, window_key, + PRESS_EVENTS, State, TEXT_SMOOTHING, apply_scroll, images, note_scroll, outbox, view, + window_key, }; use crate::runtime::{FromRuntime, RuntimeChild, spawn_runtime}; @@ -268,13 +269,9 @@ impl Session { self.timings.trees += 1; self.rt.update_if(move |s, _| s.apply(tree)); } - RuntimeMsg::Scroll { path, top, seq } => { + RuntimeMsg::Scroll { path, top, seq, by } => { note_scroll(&path, seq); - if std::env::var("NATIVE_DEBUG").is_ok() { - eprintln!("[host] scroll_to {top}") - } - self.rt - .update(move |_, tx| tx.scroll_to_instant(path, 0.0, top)) + self.rt.update(move |_, tx| apply_scroll(tx, path, top, by)) } request => { if let Some(run) = request.request() { diff --git a/apps/native/host/src/render/handlers.rs b/apps/native/host/src/render/handlers.rs index 3d97296bf..33df41b06 100644 --- a/apps/native/host/src/render/handlers.rs +++ b/apps/native/host/src/render/handlers.rs @@ -249,6 +249,21 @@ pub(crate) fn scroll_seqs() -> &'static std::sync::Mutex> { SEQS.get_or_init(Default::default) } +/// Applies a runtime scroll: `by` shifts the viewport from where it is now (`element.scrollBy`), +/// which leaves a finger's drag and a fling's momentum running; otherwise it jumps to `top`. +pub fn apply_scroll(tx: &mut rune::Tx, path: String, top: f32, by: Option) { + if std::env::var("NATIVE_DEBUG").is_ok() { + match by { + Some(by) => eprintln!("[host] scroll_by {by}"), + None => eprintln!("[host] scroll_to {top}"), + } + } + match by { + Some(by) => tx.scroll_by(path, by), + None => tx.scroll_to_instant(path, 0.0, top), + } +} + /// Notes a runtime scroll's `seq` for the offset reports after it. pub fn note_scroll(path: &str, seq: Option) { if let Some(seq) = seq { diff --git a/apps/native/host/src/render/mod.rs b/apps/native/host/src/render/mod.rs index 9eddc9b1f..44ca1646c 100644 --- a/apps/native/host/src/render/mod.rs +++ b/apps/native/host/src/render/mod.rs @@ -20,9 +20,9 @@ use crate::layout::{Inherit, apply_box, apply_flex, center_vertically, color, te use crate::wire::{self, Node, Style}; pub use events::{PRESS_EVENTS, window_key}; -pub use handlers::note_scroll; #[cfg(test)] pub(crate) use handlers::scroll_seqs; +pub use handlers::{apply_scroll, note_scroll}; pub(crate) use state::is_portal; pub use state::{Env, State, child_path, last_env}; diff --git a/apps/native/host/src/shell.rs b/apps/native/host/src/shell.rs index 2da8553d0..1aa57a3e3 100644 --- a/apps/native/host/src/shell.rs +++ b/apps/native/host/src/shell.rs @@ -76,9 +76,9 @@ pub fn route(message: RuntimeMsg) -> Routed { pub fn apply(message: RuntimeMsg, handle: &AppHandle) { match message { RuntimeMsg::Tree(tree) => handle.update_if(move |s: &mut State, _| s.apply(tree)), - RuntimeMsg::Scroll { path, top, seq } => { + RuntimeMsg::Scroll { path, top, seq, by } => { crate::note_scroll(&path, seq); - handle.update(move |_: &mut State, tx| tx.scroll_to_instant(path, 0.0, top)); + handle.update(move |_: &mut State, tx| crate::apply_scroll(tx, path, top, by)); } request => { if let Some(run) = request.request() { diff --git a/apps/native/host/src/wire.rs b/apps/native/host/src/wire.rs index ddde421aa..42835b1d9 100644 --- a/apps/native/host/src/wire.rs +++ b/apps/native/host/src/wire.rs @@ -390,6 +390,11 @@ pub enum RuntimeMsg { /// Echoed in the next offset report, so the runtime can tell reports from before it. #[serde(default)] seq: Option, + /// An `element.scrollBy()`: shift the offset by this much from wherever the viewport is + /// now (`top` is where the runtime expects it to land), keeping a finger's drag and a + /// fling's momentum. The runtime's idea of the offset is a report behind the host's. + #[serde(default)] + by: Option, }, /// Focus the element keyed `key` (an `element.focus()` in the runtime). #[serde(rename = "focus")] diff --git a/apps/native/src/ios.ts b/apps/native/src/ios.ts index f1541f8eb..390c109ba 100644 --- a/apps/native/src/ios.ts +++ b/apps/native/src/ios.ts @@ -72,8 +72,14 @@ globalThis.onmessage = (event: MessageEvent) => else if (typeof data !== "string") { replies.get(data.id)?.(data) replies.delete(data.id) - } else if (deliver === undefined) early.push(data) - else deliver(data) + } else { + // A batch of host lines (one frame's reports), split here so they run in one task. + for (const line of data.split("\n")) { + if (line === "") continue + if (deliver === undefined) early.push(line) + else deliver(line) + } + } } /** diff --git a/apps/web-foldkit/src/mount/message-list.ts b/apps/web-foldkit/src/mount/message-list.ts index df0bc0dce..5e90e22fb 100644 --- a/apps/web-foldkit/src/mount/message-list.ts +++ b/apps/web-foldkit/src/mount/message-list.ts @@ -243,8 +243,10 @@ export const ApplyScroll = Command.define("ApplyScroll", { const element = document.getElementById(id) if (element === null) return Message.SkippedApplyScroll({ version }) yield* afterLayout(element) - element.scrollTop = - adjustment._tag === "To" ? adjustment.scrollTop : element.scrollTop + adjustment.deltaPx + // `By` is relative: the native shell applies it to the offset the reader is at by then, + // so a drag or fling under way carries on instead of snapping back to a stale offset. + if (adjustment._tag === "To") element.scrollTop = adjustment.scrollTop + else element.scrollBy({ top: adjustment.deltaPx }) return Message.CompletedApplyScroll({ version, scrollTop: element.scrollTop }) }), }) diff --git a/packages/foldkit-rune/src/bridge.test.ts b/packages/foldkit-rune/src/bridge.test.ts index 384f1c6be..877bf76b1 100644 --- a/packages/foldkit-rune/src/bridge.test.ts +++ b/packages/foldkit-rune/src/bridge.test.ts @@ -84,7 +84,7 @@ describe("makeElementBridge", () => { expect(calls).toEqual(["insert", "postpatch", "destroy"]) }) - it("answers queries, sizes and ResizeObserver from host layout", () => { + it("answers queries, sizes and ResizeObserver from host layout", async () => { const bridge = makeElementBridge({ mounts: new Set(), send: () => {} }) bridge.commit(record(bridge), new Map()) const list = bridge.findById("list") @@ -97,10 +97,28 @@ describe("makeElementBridge", () => { }) observer.observe(rows[1]) bridge.reportLayout("r/1", { x: 0, y: 40, width: 300, height: 72 }) + await Promise.resolve() expect(heights).toEqual([72]) expect(rows[1]?.getBoundingClientRect().top).toBe(40) }) + it("delivers a batch of host layouts to a ResizeObserver in one callback", async () => { + const bridge = makeElementBridge({ mounts: new Set(), send: () => {} }) + bridge.commit(record(bridge), new Map()) + const rows = bridge.findById("list")?.querySelectorAll("[data-list-key]") ?? [] + const calls: Array> = [] + const observer = new bridge.ResizeObserver((entries) => { + calls.push(entries.map((entry) => entry.contentRect.height)) + }) + for (const row of rows) observer.observe(row) + bridge.reportLayout("r/0", { x: 0, y: 0, width: 300, height: 40 }) + bridge.reportLayout("r/1", { x: 0, y: 40, width: 300, height: 72 }) + // Resized again in the same batch: the entry carries the latest size, once. + bridge.reportLayout("r/1", { x: 0, y: 40, width: 300, height: 80 }) + await Promise.resolve() + expect(calls).toEqual([[40, 80]]) + }) + it("asks the host to measure an element once, when it is first observed", () => { const measured: Array = [] const bridge = makeElementBridge({ @@ -141,6 +159,27 @@ describe("makeElementBridge", () => { expect(sent).toEqual([480]) }) + it("sends scrollBy as a shift from the host's offset, and takes the host's answer", () => { + const sent: Array<{ top: number; by?: number; seq?: number }> = [] + const bridge = makeElementBridge({ + mounts: new Set(), + send: (command) => { + if (command.t === "scroll") sent.push({ top: command.top, by: command.by, seq: command.seq }) + }, + }) + bridge.commit(record(bridge), new Map()) + const list = bridge.findById("list") + if (list === null) throw new Error("no #list element") + bridge.reportScroll("r", 300) + list.scrollBy({ top: 40 }) + list.scrollBy(0, 0) + expect(list.scrollTop).toBe(340) + expect(sent).toEqual([{ top: 340, by: 40, seq: 1 }]) + // The reader kept scrolling while the shift crossed: the host's offset after it wins. + bridge.reportScroll("r", 390, 1) + expect(list.scrollTop).toBe(390) + }) + it("fires scroll once the host's rects for that frame are in, as one batch", async () => { const bridge = makeElementBridge({ mounts: new Set(), send: () => {} }) bridge.commit(record(bridge), new Map()) diff --git a/packages/foldkit-rune/src/bridge.ts b/packages/foldkit-rune/src/bridge.ts index 89b2f8899..501bd86b7 100644 --- a/packages/foldkit-rune/src/bridge.ts +++ b/packages/foldkit-rune/src/bridge.ts @@ -53,6 +53,7 @@ type ProxyElement = EventTarget & { readonly parentElement: ProxyElement | null contains(other: EventTarget | null): boolean scrollTo(arg: { top?: number } | number, y?: number): void + scrollBy(arg: { top?: number } | number, y?: number): void scrollIntoView(arg?: boolean | { block?: HostReveal["block"] }): void focus(): void blur(): void @@ -259,7 +260,7 @@ export const makeElementBridge = (options: { } const rect = () => rects.get(path) ?? { x: 0, y: 0, width: 0, height: 0 } const record = () => records.get(path) - const setScrollTop = (top: number) => { + const setScrollTop = (top: number, by?: number) => { // A write of the offset it read (an anchor kept with no change) is no move: sending it // would put back an offset the user may have scrolled on from. if (top === (scrollTops.get(path) ?? 0)) return @@ -267,7 +268,9 @@ export const makeElementBridge = (options: { clearTimeout(pendingScrolls.get(path)?.timer) const seq = ++scrollSeq pendingScrolls.set(path, { top, at: performance.now(), seq }) - options.send({ t: "scroll", path, top, seq }) + options.send( + by === undefined ? { t: "scroll", path, top, seq } : { t: "scroll", path, top, seq, by }, + ) } const proxy = Object.defineProperties(new ElementClass(), { __path: { value: path }, @@ -320,6 +323,13 @@ export const makeElementBridge = (options: { value: (arg: { top?: number } | number, y?: number) => setScrollTop(typeof arg === "number" ? (y ?? 0) : (arg.top ?? 0)), }, + // Relative, as the host applies it: what the reader scrolled since the last report stays. + scrollBy: { + value: (arg: { top?: number } | number, y?: number) => { + const by = typeof arg === "number" ? (y ?? 0) : (arg.top ?? 0) + if (by !== 0) setScrollTop((scrollTops.get(path) ?? 0) + by, by) + }, + }, scrollIntoView: { value: (arg?: boolean | { block?: HostReveal["block"] }) => options.send({ t: "reveal", path, block: revealBlock(arg) }), @@ -656,21 +666,31 @@ export const makeElementBridge = (options: { class BridgeResizeObserver { private targets = new Map void>() + /** Targets resized in the current batch of host lines, delivered in one callback. */ + private due = new Map() constructor( private callback: (entries: Array, observer: BridgeResizeObserver) => void, ) {} + /** As a browser's: one callback per frame with every target that resized in it. */ + private deliver = () => { + const entries: Array = [] + for (const [path, proxy] of this.due) { + if (!this.targets.has(path)) continue + const r = rects.get(path) ?? { x: 0, y: 0, width: 0, height: 0 } + const size = [{ inlineSize: r.width, blockSize: r.height }] + entries.push({ target: proxy, contentRect: r, borderBoxSize: size, contentBoxSize: size }) + } + this.due.clear() + if (entries.length > 0) this.callback(entries, this) + } observe(target: unknown) { if (!isProxy(target)) return const path = target.__path // An element reached by walking (`parentElement`) is not observed yet: its size is asked for. proxyFor(path) const notify = (proxy: ProxyElement) => { - const r = rects.get(path) ?? { x: 0, y: 0, width: 0, height: 0 } - const size = [{ inlineSize: r.width, blockSize: r.height }] - this.callback( - [{ target: proxy, contentRect: r, borderBoxSize: size, contentBoxSize: size }], - this, - ) + if (this.due.size === 0) queueMicrotask(this.deliver) + this.due.set(path, proxy) } this.targets.set(path, notify) const set = resizeObservers.get(path) ?? new Set() diff --git a/packages/foldkit-rune/src/wire.ts b/packages/foldkit-rune/src/wire.ts index acb2b5a13..3919c1917 100644 --- a/packages/foldkit-rune/src/wire.ts +++ b/packages/foldkit-rune/src/wire.ts @@ -178,6 +178,12 @@ export type RuntimeToHost = readonly top: number /** Echoed by the host's next offset report, so reports from before it can be told apart. */ readonly seq?: number + /** + * `element.scrollBy()`: the host shifts the viewport by this much from where it is when + * it applies it (`top` is where the runtime expects it), so a finger's drag and a fling + * carry on; the runtime's offset is always a report behind the host's. + */ + readonly by?: number } /** Focus the element keyed `key` once the host has the tree it is in (`element.focus()`). */ | { readonly t: "focus"; readonly key: string } From 5cf69b8ef7de7931de952f86d2c6bee7d8d37c57 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 20:24:48 +0100 Subject: [PATCH 2/7] perf(web): the message list's overscan window moves in chunks and outlives a settle - At steady state the overscan window grew and dropped one row each time a row boundary crossed its lead, re-rendering the list every few frames of a scroll. Each side now waits a step before growing or dropping, so it moves every few rows (126 window changes over a 6000px scroll before, under 60 now). - A settled list kept only the pool, so every new gesture built the 2400px lead again. Settled now keeps the last window rendered while it overlaps the viewport, and the next scroll goes on from it. Eight short gestures sent 32 trees to the native host; now 16. Co-Authored-By: Claude Opus 5.5 --- .../src/mount/message-list.test.ts | 81 ++++++++++++++++ apps/web-foldkit/src/mount/message-list.ts | 95 ++++++++++++++----- 2 files changed, 152 insertions(+), 24 deletions(-) diff --git a/apps/web-foldkit/src/mount/message-list.test.ts b/apps/web-foldkit/src/mount/message-list.test.ts index 346771fbf..85a4b2503 100644 --- a/apps/web-foldkit/src/mount/message-list.test.ts +++ b/apps/web-foldkit/src/mount/message-list.test.ts @@ -61,3 +61,84 @@ describe("scrollByPage", () => { expect(MessageList.scrollByPage(list(1500), "Down").model.scrollTop).toBe(2000 - 400) }) }) + +describe("the overscan window while scrolling", () => { + test("a steady scroll moves it every few rows, not at every row boundary", () => { + const rows = 400 + let model: MessageList.Model = { + ...MessageList.init({ id: "list", estimatedRowHeightPx: 50, viewportHeight: 800 }), + keys: Array.from({ length: rows }, (_, index) => `row-${index}`), + scrollTop: 0, + } + const windowOf = (current: MessageList.Model) => { + const phase = current.scrollPhase + return phase._tag === "Scrolling" ? `${phase.renderedFromKey}..${phase.renderedToKey}` : "settled" + } + let changes = 0 + let last = windowOf(model) + // 10px a frame through 6000px: 120 rows cross each edge of the window. + for (let top = 10; top <= 6000; top += 10) { + model = MessageList.update(model, MessageList.Message.ScrolledList({ scrollTop: top })).model + const now = windowOf(model) + if (now !== last) changes++ + last = now + } + expect(changes).toBeLessThan(60) + // The viewport is always inside what renders. + const phase = model.scrollPhase + if (phase._tag !== "Scrolling") throw new Error("not scrolling") + const from = model.keys.indexOf(phase.renderedFromKey ?? "") + const to = model.keys.indexOf(phase.renderedToKey ?? "") + expect(from * 50).toBeLessThanOrEqual(6000 - 300) + expect((to + 1) * 50).toBeGreaterThanOrEqual(6000 + 800 + 300) + }) +}) + +describe("the overscan window after a scroll settles", () => { + const scrolled = () => { + let model: MessageList.Model = { + ...MessageList.init({ id: "list", estimatedRowHeightPx: 50, viewportHeight: 800 }), + keys: Array.from({ length: 400 }, (_, index) => `row-${index}`), + scrollTop: 0, + } + for (let top = 3000; top <= 3600; top += 20) + model = MessageList.update(model, MessageList.Message.ScrolledList({ scrollTop: top })).model + return model + } + + test("stays rendered, and the next scroll goes on from it", () => { + const scrolling = scrolled() + const phase = scrolling.scrollPhase + if (phase._tag !== "Scrolling") throw new Error("not scrolling") + const settled = MessageList.update( + scrolling, + MessageList.Message.CompletedWaitForScrollSettle({ version: phase.eventVersion }), + ).model + expect(settled.scrollPhase).toEqual( + MessageList.ScrollPhase.Settled({ + keptFromKey: phase.renderedFromKey, + keptToKey: phase.renderedToKey, + }), + ) + expect(MessageList.keptIndexes(settled, MessageList.layoutOf(settled))).toBeDefined() + const again = MessageList.update(settled, MessageList.Message.ScrolledList({ scrollTop: 3610 })).model + const next = again.scrollPhase + if (next._tag !== "Scrolling") throw new Error("not scrolling") + expect([next.renderedFromKey, next.renderedToKey]).toEqual([ + phase.renderedFromKey, + phase.renderedToKey, + ]) + }) + + test("is let go once the viewport is elsewhere", () => { + const scrolling = scrolled() + const phase = scrolling.scrollPhase + if (phase._tag !== "Scrolling") throw new Error("not scrolling") + const settled = MessageList.update( + scrolling, + MessageList.Message.CompletedWaitForScrollSettle({ version: phase.eventVersion }), + ).model + const away = { ...settled, scrollTop: 18000 } + expect(MessageList.keptIndexes(away, MessageList.layoutOf(away))).toBeUndefined() + }) +}) diff --git a/apps/web-foldkit/src/mount/message-list.ts b/apps/web-foldkit/src/mount/message-list.ts index 5e90e22fb..54c973953 100644 --- a/apps/web-foldkit/src/mount/message-list.ts +++ b/apps/web-foldkit/src/mount/message-list.ts @@ -34,7 +34,11 @@ export type ViewportAnchor = typeof ViewportAnchor.Type * change no DOM. */ export const ScrollPhase = defineTaggedUnion({ - Settled: {}, + /** + * The last scroll's overscan window stays rendered (beside the pool) while it overlaps the + * viewport, so the next scroll starts from it instead of building those rows again. + */ + Settled: { keptFromKey: Schema.NullOr(Schema.String), keptToKey: Schema.NullOr(Schema.String) }, Scrolling: { eventVersion: Schema.Number, renderedFromKey: Schema.NullOr(Schema.String), @@ -113,7 +117,7 @@ export const init = (config: { scrollVersion: 0, appliedScrollVersion: 0, pool: emptyPool, - scrollPhase: ScrollPhase.Settled(), + scrollPhase: ScrollPhase.Settled({ keptFromKey: null, keptToKey: null }), activeStickyKey: null, stuckKey: null, }) @@ -291,19 +295,36 @@ const changedMeasurements = ( /** * The overscan window always covers the viewport plus `RENDER_MARGIN_PX`, then grows toward * `RENDER_LEAD_PX` by at most `RENDER_STEP_PX` per side and update, so a frame builds a few rows, - * never a 2400px chunk. Rows past the lead plus `RENDER_SLACK_PX` are dropped. + * never a 2400px chunk. Rows past the lead plus `RENDER_SLACK_PX` are dropped. Both wait a step + * before acting, so a steady scroll re-renders the window every few rows. */ const RENDER_MARGIN_PX = 300 const RENDER_LEAD_PX = 2400 const RENDER_STEP_PX = 320 const RENDER_SLACK_PX = 600 +const indexesOf = (layout: Layout, fromKey: string | null, toKey: string | null) => { + const from = fromKey === null ? undefined : layout.indexByKey.get(fromKey) + const to = toKey === null ? undefined : layout.indexByKey.get(toKey) + return from === undefined || to === undefined || from > to ? undefined : { from, to } +} + export const renderedIndexes = (model: Model, layout: Layout) => { const phase = model.scrollPhase if (phase._tag === "Settled") return undefined - const from = phase.renderedFromKey === null ? undefined : layout.indexByKey.get(phase.renderedFromKey) - const to = phase.renderedToKey === null ? undefined : layout.indexByKey.get(phase.renderedToKey) - return from === undefined || to === undefined || from > to ? undefined : { from, to } + return indexesOf(layout, phase.renderedFromKey, phase.renderedToKey) +} + +/** The window the last scroll left rendered, while it still covers part of the viewport. */ +export const keptIndexes = (model: Model, layout: Layout) => { + const phase = model.scrollPhase + if (phase._tag === "Scrolling" || model.viewportHeight === 0) return undefined + const kept = indexesOf(layout, phase.keptFromKey, phase.keptToKey) + if (kept === undefined) return undefined + const top = Math.max(0, model.scrollTop) + const viewFrom = rowIndexAt(layout, top) + const viewTo = rowIndexAt(layout, top + model.viewportHeight) + return kept.to < viewFrom || kept.from > viewTo ? undefined : kept } const keepOverscan = (model: Model): Model => { @@ -320,16 +341,26 @@ const keepOverscan = (model: Model): Model => { const isDisjoint = current === undefined || current.to < hardFrom || current.from > hardTo const coveredFrom = isDisjoint ? hardFrom : Math.min(current.from, hardFrom) const coveredTo = isDisjoint ? hardTo : Math.max(current.to, hardTo) - const grownFrom = Math.max( - at(top - RENDER_LEAD_PX), - Math.min(coveredFrom, at(layout.offsets[coveredFrom]! - RENDER_STEP_PX)), - ) - const grownTo = Math.min( - at(bottom + RENDER_LEAD_PX), - Math.max(coveredTo, at(layout.offsets[coveredTo + 1]! + RENDER_STEP_PX)), - ) - const from = Math.max(Math.min(coveredFrom, grownFrom), at(top - RENDER_LEAD_PX - RENDER_SLACK_PX)) - const to = Math.min(Math.max(coveredTo, grownTo), at(bottom + RENDER_LEAD_PX + RENDER_SLACK_PX)) + // A side grows once its lead has run down by a step, and drops rows once they are a step past + // the slack: a steady scroll changes the window every few rows, not at every row boundary. + const coveredTop = layout.offsets[coveredFrom]! + const coveredBottom = layout.offsets[coveredTo + 1]! + const grownFrom = + coveredTop <= top - RENDER_LEAD_PX + RENDER_STEP_PX + ? coveredFrom + : Math.max(at(top - RENDER_LEAD_PX), Math.min(coveredFrom, at(coveredTop - RENDER_STEP_PX))) + const grownTo = + coveredBottom >= bottom + RENDER_LEAD_PX - RENDER_STEP_PX + ? coveredTo + : Math.min(at(bottom + RENDER_LEAD_PX), Math.max(coveredTo, at(coveredBottom + RENDER_STEP_PX))) + const dropAbove = top - RENDER_LEAD_PX - RENDER_SLACK_PX + const dropBelow = bottom + RENDER_LEAD_PX + RENDER_SLACK_PX + const from = + layout.offsets[grownFrom + 1]! < dropAbove - RENDER_STEP_PX + ? at(dropAbove) + : Math.min(coveredFrom, grownFrom) + const to = + layout.offsets[grownTo]! > dropBelow + RENDER_STEP_PX ? at(dropBelow) : Math.max(coveredTo, grownTo) if (current !== undefined && from === current.from && to === current.to) return model return modifyFields(model, { scrollPhase: () => @@ -351,10 +382,16 @@ const startedScrolling = (model: Model): ListReturn => ScrollPhase.Scrolling({ ...phase, eventVersion: Number.increment(phase.eventVersion) }), }), }), - Settled: () => ({ + // The window the last scroll left goes on from where it is (or starts over, when it is + // far from the viewport now). + Settled: ({ keptFromKey, keptToKey }) => ({ model: modifyFields(model, { scrollPhase: () => - ScrollPhase.Scrolling({ eventVersion: 1, renderedFromKey: null, renderedToKey: null }), + ScrollPhase.Scrolling({ + eventVersion: 1, + renderedFromKey: keptFromKey, + renderedToKey: keptToKey, + }), }), commands: [WaitForScrollSettle({ version: 1 })], }), @@ -442,9 +479,17 @@ const updateScroll = (model: Model, message: Message): ListReturn => CompletedWaitForScrollSettle: ({ version }) => ScrollPhase.match(model.scrollPhase, { Settled: () => ({ model }), - Scrolling: ({ eventVersion }) => + Scrolling: ({ eventVersion, renderedFromKey, renderedToKey }) => version === eventVersion - ? { model: modifyFields(model, { scrollPhase: () => ScrollPhase.Settled() }) } + ? { + model: modifyFields(model, { + scrollPhase: () => + ScrollPhase.Settled({ + keptFromKey: renderedFromKey, + keptToKey: renderedToKey, + }), + }), + } : { model, commands: [WaitForScrollSettle({ version: eventVersion })] }, }), ResizedViewport: ({ viewportHeight }) => @@ -701,7 +746,7 @@ export const isViewEqual = (a: Model, b: Model): boolean => const isSamePhaseView = (a: ScrollPhase, b: ScrollPhase): boolean => a === b || (a._tag === "Settled" - ? b._tag === "Settled" + ? b._tag === "Settled" && a.keptFromKey === b.keptFromKey && a.keptToKey === b.keptToKey : b._tag === "Scrolling" && a.renderedFromKey === b.renderedFromKey && a.renderedToKey === b.renderedToKey) @@ -781,15 +826,17 @@ export const view = ( // absolute offsets, so DOM order is not visible, and keeping it the same means a phase switch // never moves a row's node. Moving a node reloads any iframe in it (HTML renders flash). const containerRows = () => { - const indexes: Array = [] + const indexes = new Set() const empty: Array = [] for (let slot = 0; slot < model.pool.pooledCount; slot++) { const key = model.pool.slots[slot] ?? null const index = key === null ? undefined : layout.indexByKey.get(key) if (index === undefined) empty.push(slot) - else indexes.push(index) + else indexes.add(index) } - return [...indexes.sort((a, b) => a - b).map(row), ...empty.map(emptyContainer)] + const kept = keptIndexes(model, layout) + if (kept !== undefined) for (let index = kept.from; index <= kept.to; index++) indexes.add(index) + return [...[...indexes].sort((a, b) => a - b).map(row), ...empty.map(emptyContainer)] } const rows = model.viewportHeight === 0 From ad9fb33512e4b78442aa830e19b9597384dfd301 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 20:27:56 +0100 Subject: [PATCH 3/7] perf(ios): the tab bar asks UIKit for its height once per width sizeThatFits lays the bar out; the island sync ran it after every frame. Co-Authored-By: Claude Opus 5.5 --- apps/native/host/ios/src/islands.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/native/host/ios/src/islands.rs b/apps/native/host/ios/src/islands.rs index a5c7e9e6d..396b3788d 100644 --- a/apps/native/host/ios/src/islands.rs +++ b/apps/native/host/ios/src/islands.rs @@ -119,6 +119,9 @@ struct Island { spec: IslandSpec, /// The size of the part that shows and the holes in it, when anything covers the view. mask: Option<([f32; 2], Vec)>, + /// A tab bar's height for a width, as `sizeThatFits` last said: asked once per width, not + /// in every frame (it lays the bar out). + fitted: Option<(f32, f32)>, } thread_local! { @@ -478,6 +481,7 @@ fn create(mtm: MainThreadMarker, parent: &UIView, spec: &IslandSpec) -> Option { let bottom = y + h; - let fitted = bar.sizeThatFits(CGSize::new(w as f64, 0.0)).height as f32; + let fitted = match island.fitted { + Some((width, height)) if width == w => height, + _ => { + let height = bar.sizeThatFits(CGSize::new(w as f64, 0.0)).height as f32; + island.fitted = Some((w, height)); + height + } + }; // The bar adds the home indicator's inset itself once it is in the window. let height = if fitted > bottom_inset + 1.0 { fitted From 81b3dd68713c5a29f4f1ebd9196f67cee36d431c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 20:33:52 +0100 Subject: [PATCH 4/7] fix(native): a highlighted code block breaks its lines at its newlines A white-space: pre block of inline content laid out as one wrapping row, so a newline inside the editor's leaf spans (each leaf wraps its text in two spans) started no line: a code block ran its lines together. Such content now splits at its newlines into a column of line rows, fragmenting an inline element across the lines as CSS does. Co-Authored-By: Claude Opus 5.5 --- apps/native/host/src/render/mod.rs | 132 +++++++++++++++++++++++++++++ apps/native/host/src/tests.rs | 46 ++++++++++ 2 files changed, 178 insertions(+) diff --git a/apps/native/host/src/render/mod.rs b/apps/native/host/src/render/mod.rs index 44ca1646c..65f056bbb 100644 --- a/apps/native/host/src/render/mod.rs +++ b/apps/native/host/src/render/mod.rs @@ -218,6 +218,109 @@ fn build(node: &Arc, path: &str, inh: Inherit, ctx: &Ctx) -> Option { } } +/// `white-space: pre` inline children split at the newlines in their text, one list per line, +/// or None when no text has one. A newline inside an inline element (each of an editor's leaves +/// wraps its text in spans) fragments that element across the lines, as CSS does an inline box. +/// An empty line holds a space, so it keeps a line's height; the newline that ends the content +/// starts no line of its own, as in a browser. +fn split_pre_lines(kids: &[(usize, &Arc)]) -> Option)>>> { + let pieces: Vec>>>> = + kids.iter().map(|(_, kid)| pre_fragments(kid)).collect(); + if pieces.iter().all(Option::is_none) { + return None; + } + let mut lines: Vec)>> = vec![Vec::new()]; + for ((i, kid), split) in kids.iter().zip(pieces) { + let Some(fragments) = split else { + lines.last_mut().expect("a line").push((*i, (*kid).clone())); + continue; + }; + for (n, fragment) in fragments.into_iter().enumerate() { + if n > 0 { + lines.push(Vec::new()); + } + if let Some(fragment) = fragment { + lines.last_mut().expect("a line").push((*i, fragment)); + } + } + } + if lines.len() > 1 && lines.last().is_some_and(Vec::is_empty) { + lines.pop(); + } + for line in &mut lines { + if line.is_empty() { + line.push((0, Arc::new(Node::Text(" ".to_string())))); + } + } + Some(lines) +} + +/// A node's parts on each line its newlines start, or None when it holds no newline: text +/// splits at them, and an inline element takes its children's parts line by line. +fn pre_fragments(node: &Arc) -> Option>>> { + match node.as_ref() { + Node::Text(text) if text.contains('\n') => Some( + text.split('\n') + .map(|piece| (!piece.is_empty()).then(|| Arc::new(Node::Text(piece.to_string())))) + .collect(), + ), + Node::Elem { children, .. } if is_inline(node) => { + let split: Vec<_> = children.iter().map(pre_fragments).collect(); + if split.iter().all(Option::is_none) { + return None; + } + let mut lines: Vec>> = vec![Vec::new()]; + for (child, split) in children.iter().zip(split) { + let Some(fragments) = split else { + lines.last_mut().expect("a line").push(child.clone()); + continue; + }; + for (n, fragment) in fragments.into_iter().enumerate() { + if n > 0 { + lines.push(Vec::new()); + } + lines.last_mut().expect("a line").extend(fragment); + } + } + Some( + lines + .into_iter() + .map(|line| (!line.is_empty()).then(|| Arc::new(with_children(node, line)))) + .collect(), + ) + } + _ => None, + } +} + +/// An element as it is, holding `children` instead of its own. +fn with_children(node: &Node, children: Vec>) -> Node { + match node { + Node::Elem { + hash, + tag, + key, + st, + attrs, + on, + observed, + ov, + .. + } => Node::Elem { + hash: hash.clone(), + tag: tag.clone(), + key: key.clone(), + st: st.clone(), + attrs: attrs.clone(), + on: on.clone(), + observed: *observed, + ov: ov.clone(), + children, + }, + Node::Text(_) | Node::Svg { .. } => Node::Text(String::new()), + } +} + #[allow(clippy::too_many_arguments)] fn build_elem( path: &str, @@ -278,7 +381,36 @@ fn build_elem( .grid_cols .as_deref() .or(st.grid_rows.as_ref().map(|_| &one_column[..])); + // `white-space: pre` inline content (a highlighted code block: token spans with the line + // breaks in the text between them) breaks its lines at its newlines, which a wrapping row + // would run together. + let pre_lines = if inh.pre + && matches!(st.display.as_deref(), None | Some("block") | Some("inline")) + && grid_cols.is_none() + && kids.iter().all(|(_, c)| is_inline(c)) + { + split_pre_lines(&kids) + } else { + None + }; let mut e = match (st.display.as_deref(), grid_cols) { + _ if let Some(lines) = &pre_lines => { + let parent = Parent { + is_row: true, + items: Some("start"), + block: false, + }; + let mut column = crate::fx::under(apply_flex(col(), st), attrs, st, path); + for (n, line) in lines.iter().enumerate() { + let line: Vec<(usize, &Arc)> = line.iter().map(|(i, c)| (*i, c)).collect(); + // Each line is its own parent: an element fragmented across lines has a part on + // each, under a path of its own. + let line_path = format!("{path}/line{n}"); + let r = add_children(inline_row(), &line, &line_path, inh, &inline_ctx, parent); + column = column.child(r.key(line_path)); + } + column + } // `overflow-y: auto` on a grid (the command palette's list). A Rune viewport scrolls only // in a bounded height, so a grid taller than its `max-h` becomes a viewport exactly that // tall; a shorter one stays a plain grid at its own height. diff --git a/apps/native/host/src/tests.rs b/apps/native/host/src/tests.rs index ad8440d8f..fc07148ad 100644 --- a/apps/native/host/src/tests.rs +++ b/apps/native/host/src/tests.rs @@ -3582,3 +3582,49 @@ fn a_fixed_frame_is_its_own_layer() { assert!(s.frames[0].clips.is_empty(), "{:?}", s.frames[0].clips); assert!(!s.covers.iter().any(|cover| cover.is_over(&s.frames[0]))); } + +/// A highlighted code block as the editor's viewer draws one: each leaf wraps its text in two +/// spans, so the line breaks are inside inline elements. Each newline starts a line, as +/// `white-space: pre` does; the last one starts none. +#[test] +fn pre_code_with_token_spans_breaks_at_its_newlines() { + let leaf = |s: &str| { + e( + "span", + json!({ "color": "#f00" }), + vec![e("span", json!({}), vec![t(s)])], + ) + }; + let rt = runtime( + e( + "div", + json!({ "w": 400, "h": 400 }), + vec![e( + "pre", + json!({ "whiteSpacePre": true, "fontSize": 14, "lineHeight": 20 }), + vec![e( + "code", + json!({}), + vec![ + leaf("const"), + leaf(" results = {\n "), + leaf("boot"), + leaf(": 412,\n\n "), + leaf("ready"), + leaf(": 731,\n}\n"), + ], + )], + )], + ), + (400.0, 400.0), + ); + let pre = rect(&rt, "r/0"); + // Five lines: `const results = {`, `boot: 412,`, an empty one, `ready: 731,` and `}`. + assert!((pre[3] - 100.0).abs() < 1.0, "five lines of 20: {pre:?}"); + let first = rect(&rt, "r/0/line0"); + let boot = rect(&rt, "r/0/line1"); + assert!( + boot[1] >= first[1] + first[3] - 0.5, + "boot starts its own line: {first:?} {boot:?}" + ); +} From 31fbbd9f7f90c62eca6c1169da0bb1ced8bbc1d9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 20:39:34 +0100 Subject: [PATCH 5/7] perf(native): a scrolling row's layout report skips building a JSON value Every observed row reports its rect each frame of a scroll. The report's prefix (with the escaped path) is now serialized once per build and the rect appended to it, instead of a serde_json::Value built and formatted per report: its share of the main thread in a long scroll drops by half. Co-Authored-By: Claude Opus 5.5 --- apps/native/host/src/outbox.rs | 7 ++++- apps/native/host/src/render/handlers.rs | 37 ++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/native/host/src/outbox.rs b/apps/native/host/src/outbox.rs index bf130c1d1..88804c63c 100644 --- a/apps/native/host/src/outbox.rs +++ b/apps/native/host/src/outbox.rs @@ -32,7 +32,12 @@ pub fn set_writer(out: Box) { } pub fn write_line(message: serde_json::Value) { - let line = format!("{message}\n"); + write_text(format!("{message}\n")); +} + +/// A line already serialized, newline included: for reports sent every frame (a scrolling +/// row's rect), which skip building a `serde_json::Value` each time. +pub fn write_text(line: String) { // Tests read what was sent right after the frame that sent it. if cfg!(test) { write_now(&line); diff --git a/apps/native/host/src/render/handlers.rs b/apps/native/host/src/render/handlers.rs index 33df41b06..f71494e2f 100644 --- a/apps/native/host/src/render/handlers.rs +++ b/apps/native/host/src/render/handlers.rs @@ -8,7 +8,7 @@ use serde_json::json; use crate::html::{focusable, is_checkable, script_focusable}; use crate::layout::focus_styles; -use crate::outbox::write_line; +use crate::outbox::{write_line, write_text}; use crate::wire::Style; use super::Ctx; @@ -211,12 +211,13 @@ pub(super) fn behaviour( /// An element a runtime Mount observes: report its layout, and its offset when it scrolls. pub(super) fn observe(e: El, path: &str, st: &Style) -> El { - let layout_path = path.to_string(); + // Serialized once per build: inside a scrolling list every observed row reports each frame. + let prefix = format!(r#"{{"t":"layout","path":{},"#, json!(path)); // A report for the runtime, no change to the host's own state: no need to build its view // again, which inside a scrolling list would be every frame. - let e = e.on_layout_tx(move |_: &mut State, [x, y, w, h]: [f32; 4], tx| { + let e = e.on_layout_tx(move |_: &mut State, rect: [f32; 4], tx| { tx.live_only(); - write_line(json!({ "t": "layout", "path": layout_path, "x": x, "y": y, "w": w, "h": h })); + write_text(layout_line(&prefix, rect)); }); if st.scroll_y != Some(true) { return e; @@ -241,6 +242,13 @@ pub(super) fn observe(e: El, path: &str, st: &Style) -> El { }) } +/// A layout report after its serialized `{"t":"layout","path":…,` prefix. A coordinate that is +/// not finite has no JSON number and reports as zero. +fn layout_line(prefix: &str, rect: [f32; 4]) -> String { + let [x, y, w, h] = rect.map(|v| if v.is_finite() { v } else { 0.0 }); + format!("{prefix}\"x\":{x},\"y\":{y},\"w\":{w},\"h\":{h}}}\n") +} + /// The last runtime scroll the host applied to each viewport: its offset reports carry that /// scroll's `seq`, so the runtime can tell the ones sent before it apart. pub(crate) fn scroll_seqs() -> &'static std::sync::Mutex> { @@ -280,3 +288,24 @@ pub(super) fn focus_field_on_press(e: El, path: &str) -> El { e.cursor(Cursor::Text) .on_press_tx(move |_: &mut State, tx| tx.focus(field.clone())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_layout_line_is_the_json_the_runtime_reads() { + let path = r#"r/0/k"quoted" key"#; + let prefix = format!(r#"{{"t":"layout","path":{},"#, json!(path)); + let line = layout_line(&prefix, [1.5, -20.25, 300.0, f32::NAN]); + assert!(line.ends_with('\n')); + let parsed: serde_json::Value = serde_json::from_str(line.trim_end()).unwrap(); + assert_eq!(parsed["t"], "layout"); + assert_eq!(parsed["path"], path); + let at = |k: &str| parsed[k].as_f64().unwrap(); + assert_eq!( + [at("x"), at("y"), at("w"), at("h")], + [1.5, -20.25, 300.0, 0.0] + ); + } +} From 3f0bece0ed347b8a8b30a4a6a373a2abc1a70118 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 20:51:41 +0100 Subject: [PATCH 6/7] perf(web,native): the keymap resolves once per overrides, host lines parse with JSON.parse - Every subscription reads the shared context on every Message, which resolved the whole keymap again, parsing each action's key combo: most of the runtime's time in a scroll on the native app (8.9s of a 20s scroll run). The keymap now keeps one table per overrides object and platform. - The native runtime decoded each host line with a Schema of Unknown; JSON.parse does the same for the line every observed row sends each frame. Co-Authored-By: Claude Opus 5.5 --- .../src/core/lib/keybindings/keymap.test.ts | 7 +++++++ .../src/core/lib/keybindings/keymap.ts | 12 ++++++++++++ packages/foldkit-rune/src/stdio.ts | 16 +++++++++------- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts b/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts index 4ff01ac02..8abffbbb7 100644 --- a/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts +++ b/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts @@ -20,6 +20,13 @@ describe("defineKeymap", () => { }) }) + test("the same overrides resolve to the same table, per platform", () => { + const overrides = { open: combo("Mod+P") } + expect(keymap.resolve(overrides, "mac")).toBe(keymap.resolve(overrides, "mac")) + expect(keymap.resolve(overrides, "windows")).not.toBe(keymap.resolve(overrides, "mac")) + expect(keymap.resolve({ ...overrides }, "mac")).toEqual(keymap.resolve(overrides, "mac")) + }) + test("an override replaces the default; null unbinds", () => { expect(keymap.resolve({ open: combo("Mod+P"), inbox: null }, "mac")).toMatchObject({ open: "Mod+P", diff --git a/apps/web-foldkit/src/core/lib/keybindings/keymap.ts b/apps/web-foldkit/src/core/lib/keybindings/keymap.ts index feb07463f..b9a199ed5 100644 --- a/apps/web-foldkit/src/core/lib/keybindings/keymap.ts +++ b/apps/web-foldkit/src/core/lib/keybindings/keymap.ts @@ -128,7 +128,19 @@ export const defineKeymap = >>() const resolve = (overrides: Overrides, platform: Platform): Keys => { + const byPlatform = resolved.get(overrides) ?? new Map>() + const cached = byPlatform.get(platform) + if (cached !== undefined) return cached + const keys = resolveFresh(overrides, platform) + byPlatform.set(platform, keys) + resolved.set(overrides, byPlatform) + return keys + } + const resolveFresh = (overrides: Overrides, platform: Platform): Keys => { // Overrides first, in keymap order: a later one that repeats a claimed key is unbound. const claimed = new Set() const overridden = new Map() diff --git a/packages/foldkit-rune/src/stdio.ts b/packages/foldkit-rune/src/stdio.ts index 32f0572a8..eb1480fbb 100644 --- a/packages/foldkit-rune/src/stdio.ts +++ b/packages/foldkit-rune/src/stdio.ts @@ -1,9 +1,5 @@ -import { Result, Schema } from "effect" - import type { HostToRuntime, RuntimeToHost } from "./wire" -const decodeJson = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Unknown)) - /** Every host message's tag, keyed by the wire type: a new kind of message cannot be left out. */ const HOST_MESSAGE_TAGS = { event: true, @@ -37,9 +33,15 @@ const isHostMessage = (value: unknown): value is HostToRuntime => /** One line from the host as a message, or the reason it is not one. */ export const parseHostLine = (line: string): { message: HostToRuntime } | { error: string } => { - const decoded = decodeJson(line) - if (Result.isFailure(decoded)) return { error: decoded.failure.message } - return isHostMessage(decoded.success) ? { message: decoded.success } : { error: "not a host message" } + // `JSON.parse`, not a Schema decode: the host sends a line per observed row each frame of a + // scroll, and a decoder of `Unknown` checks nothing JSON.parse does not. + let value: unknown + try { + value = JSON.parse(line) + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } + return isHostMessage(value) ? { message: value } : { error: "not a host message" } } /** Splits a stream into lines; a malformed one is reported and skipped, never fatal. */ From 8544251f1c1dc428b5832b07afced8e6bef0c451 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 21:00:53 +0100 Subject: [PATCH 7/7] test(native): the sidebar menu check hovers a channel row again A Favorites section and the Members row moved the channel rows down: the check hovered Members, so the trigger clickLabel pressed (the first in the tree) stayed hidden and the press followed its row's link. It also fails on main. Co-Authored-By: Claude Opus 5.5 --- apps/native/bench/interaction/run.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/native/bench/interaction/run.ts b/apps/native/bench/interaction/run.ts index f7970b53e..47a01ca2a 100644 --- a/apps/native/bench/interaction/run.ts +++ b/apps/native/bench/interaction/run.ts @@ -36,7 +36,9 @@ const OVERLAYS: ReadonlyArray = [ { name: "org menu", open: [{ clickText: "Hazel" }], shows: "Switch Server", toggles: true }, { name: "sidebar channel menu", - open: [{ move: [200, 220] }, { wait: 250 }, { clickLabel: "Channel options" }], + // Over Favorites' first channel, the first row with a dots trigger: `clickLabel` presses + // the first trigger in the tree, which shows only while its own row is hovered. + open: [{ move: [200, 270] }, { wait: 250 }, { clickLabel: "Channel options" }], // The item a panel drawn inside its row (clipped there) would still show. shows: "Settings", toggles: true,