From 120e1f56312c542bed97dbc069d050d1df5681c6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 18:56:40 +0100 Subject: [PATCH 1/2] fix(native): iOS swipe back like Slack's, tabs that never wait - The swipe back starts anywhere on the page (heading right), not only at the left edge. Pictures move, not the page: let go, the app goes back at once and draws where it goes while the page slides off, on a spring that starts at the finger's speed; where the flick would carry it decides whether it goes back. With nothing pictured underneath it slides over a blank page, not black, and the keyboard goes with the page. - A press on the tab bar no longer freezes the screen (and the bar's own selection) under a picture until the tab is drawn; a slow tab no longer flicks the bar back to the one it left. Co-Authored-By: Claude Opus 5.5 --- apps/native/README.md | 4 +- apps/native/host/ios/src/islands.rs | 12 +- apps/native/host/ios/src/system.rs | 360 +++++++++++++++++++--------- 3 files changed, 258 insertions(+), 118 deletions(-) diff --git a/apps/native/README.md b/apps/native/README.md index 96decba9d..2d9c9251c 100644 --- a/apps/native/README.md +++ b/apps/native/README.md @@ -142,7 +142,9 @@ one view, so the runtime tells the host before every in-app navigation (`navigat history shim), and the host keeps a picture of the page it leaves. A page that leads back to the one left (its back chevron mounts `SwipeBack`, `web-foldkit/src/platform/system-ui.ts`) slides in over it from the right; going back slides the page off over the picture of where it goes; a drag -from the left edge does that under the finger. Tabs change at once. +to the right from anywhere on the page (as in Slack) does that under the finger, and when let go +finishes at the finger's speed, the runtime drawing where it goes while the page slides off. A +press on the tab bar never waits on a picture: the bar and the page stay live while the tab draws. **Live data** (`HAZEL_DATA=live bun run ios`, with `VITE_APP_URL`/`VITE_BACKEND_URL` for a dev stack on this Mac) signs in as the Mac app does: the sign-in link opens in the system's sign-in diff --git a/apps/native/host/ios/src/islands.rs b/apps/native/host/ios/src/islands.rs index a5c7e9e6d..5623c1e28 100644 --- a/apps/native/host/ios/src/islands.rs +++ b/apps/native/host/ios/src/islands.rs @@ -38,8 +38,9 @@ use objc2_ui_kit::{ }; /// How long a tab the reader pressed stays selected before the app's own selection wins: the -/// app answers a press with a tree that selects it, or (the Menu item) one that does not. -const OPTIMISTIC: Duration = Duration::from_millis(350); +/// app answers a press with a tree that selects it, or (the Menu item) one that does not. A tree +/// that selects it ends the wait sooner, so a slow tab never flicks back to the one it left. +const OPTIMISTIC: Duration = Duration::from_millis(800); type Rect = [f32; 4]; @@ -88,6 +89,8 @@ define_class!( #[unsafe(method(tabBar:didSelectItem:))] fn did_select(&self, _bar: &UITabBar, item: &UITabBarItem) { self.ivars().pressed.set(Some(Instant::now())); + // Its navigation changes at once: nothing freezes the bar while the tab is drawn. + crate::system::tab_pressed(); if let Some(item) = self.ivars().items.borrow().get(item.tag() as usize) { islands::press(item); } @@ -548,6 +551,11 @@ fn put(island: &mut Island, placement: &Placement, bottom_inset: f32) { } island.host.setHidden(false); if let Native::TabBar { bar, delegate } = &island.native { + let wanted = island.spec.items.iter().position(|item| item.selected); + let shown = bar.selectedItem().map(|item| item.tag() as usize); + if wanted == shown { + delegate.ivars().pressed.set(None); + } let settled = delegate .ivars() .pressed diff --git a/apps/native/host/ios/src/system.rs b/apps/native/host/ios/src/system.rs index d64da99d1..7fd314195 100644 --- a/apps/native/host/ios/src/system.rs +++ b/apps/native/host/ios/src/system.rs @@ -8,9 +8,10 @@ //! - a page that leads back to the one left (it has a back chevron to it) slides in from the right //! over it, the old page drifting left and dimming (a push); //! - going back slides the page off to the right, over the picture of the page it goes to (a pop); -//! - the swipe from the left edge does the same under the finger, and goes back when let go far -//! or fast enough; -//! - anything else (a tab) changes at once. +//! - a swipe to the right, starting anywhere on the page as in Slack, does the same under the +//! finger, and goes back when let go far or fast enough (where the flick would carry it); +//! - anything else changes at once, and a press on the tab bar never waits: the bar and the page +//! stay live while the runtime draws the tab. use std::cell::{Cell, RefCell}; use std::time::{Duration, Instant}; @@ -62,16 +63,48 @@ thread_local! { static BACK: RefCell> = const { RefCell::new(None) }; /// A navigation waiting for its page to be drawn. static PENDING: RefCell> = const { RefCell::new(None) }; - /// A swipe went back: the picture covers the page until the runtime has drawn it. - static SWIPED: RefCell>> = const { RefCell::new(None) }; + /// A swipe went back: the picture of where it goes covers the page until the runtime has + /// drawn it there. + static RETURNING: RefCell> = const { RefCell::new(None) }; + /// When the tab bar was last pressed: the navigation it makes changes at once. + static TAB_PRESSED: Cell> = const { Cell::new(None) }; /// A slide is moving: a navigation in the middle of one changes at once. static MOVING: Cell = const { Cell::new(false) }; static SWIPE: RefCell, Retained)>> = const { RefCell::new(None) }; - /// What the swipe moving now slides over: the picture, and the dim over it. - static UNDER: RefCell, Retained)>> = const { RefCell::new(None) }; + /// The swipe under the finger now. + static DRAG: RefCell> = const { RefCell::new(None) }; } +/// A swipe going back, until the page it goes to is drawn under its picture. +struct Returning { + cover: Retained, + /// The runtime has said it moves (`navigate`): the next tree it draws is that page. + navigated: bool, + /// That page is drawn under the cover. + drawn: bool, + /// The slide has come to rest. + settled: bool, +} + +/// A swipe under the finger. Pictures move, not the page: the page stays where it is underneath, +/// so the runtime can draw where the swipe goes while the slide finishes. +struct Drag { + /// The page being left, pictured: follows the finger. + leaving: Retained, + /// The page it goes back to, pictured (or a blank page), drifting in from the left. + under: Retained, + dim: Retained, +} + +/// The tab bar was pressed (`islands`): the navigation it makes changes at once. +pub fn tab_pressed() { + TAB_PRESSED.set(Some(Instant::now())); +} + +/// How long after a press on the tab bar its navigation still counts as the tab's. +const TAB_NAVIGATES: Duration = Duration::from_millis(600); + struct Pending { /// The page left, frozen over the page while the next one is drawn. cover: Retained, @@ -122,13 +155,32 @@ pub fn navigate(mtm: MainThreadMarker, json: &str) { ) else { return; }; - // A swipe already moved the page; a slide still moving lets this one change at once. - if SWIPED.with_borrow(Option::is_some) || MOVING.get() { + // A swipe already moved the page: the tree this navigation draws uncovers it. + let swiped = RETURNING.with_borrow_mut(|returning| { + returning + .as_mut() + .map(|returning| returning.navigated = true) + .is_some() + }); + // A slide still moving lets this one change at once. + if swiped || MOVING.get() { return; } let (Some(window), Some(page)) = (window(mtm), page(mtm)) else { return; }; + // A tab changes at once: nothing covers the page (or the bar, whose selection moves under the + // finger) while the runtime draws the tab. Its picture is kept for a page that leads back. + if TAB_PRESSED + .take() + .is_some_and(|at| at.elapsed() < TAB_NAVIGATES) + { + finish_pending(); + if let Some(kept) = picture(&page) { + keep(from, kept); + } + return; + } let (Some(kept), Some(cover)) = (picture(&page), picture(&page)) else { return; }; @@ -152,13 +204,22 @@ pub fn navigate(mtm: MainThreadMarker, json: &str) { /// A tree from the runtime was applied: once Rune has drawn it, a waiting navigation moves. pub fn tree_applied() { - if SWIPED.with_borrow(Option::is_some) { - later(DRAWN, || { - if let Some(cover) = SWIPED.take() { - fade_out(cover); - } - }); - return; + let returning = RETURNING.with_borrow(|returning| returning.as_ref().map(|r| r.navigated)); + match returning { + Some(true) => { + later(DRAWN, || { + RETURNING.with_borrow_mut(|returning| { + if let Some(returning) = returning { + returning.drawn = true; + } + }); + finish_return(false); + }); + return; + } + // A tree from before the runtime moved: not yet the page the swipe goes to. + Some(false) => return, + None => {} } if PENDING.with_borrow(Option::is_none) { return; @@ -310,14 +371,17 @@ fn fade_out(cover: Retained) { // SWIPE BACK -/// A swipe starts this close (points) to the left edge, as a navigation stack's does. -const EDGE: CGFloat = 24.0; -/// A swipe let go past this share of the width goes back; short of it, the page returns. -const COMMIT_SHARE: CGFloat = 0.35; +/// A swipe let go where its flick would carry it past this share of the width goes back; short of +/// it, the page returns. +const COMMIT_SHARE: CGFloat = 0.5; +/// How far (seconds of its speed) a flick is projected when it is let go, as UIKit decelerates one. +const PROJECTION: CGFloat = 0.2; /// A flick this fast (points per second) goes back however short. -const COMMIT_VELOCITY: CGFloat = 500.0; +const COMMIT_VELOCITY: CGFloat = 700.0; +/// How long the page takes to finish the slide after it is let go. +const RELEASE: f64 = 0.32; -/// Where the back chevron on screen leads (a path); empty when there is none. The edge swipe is +/// Where the back chevron on screen leads (a path); empty when there is none. The swipe back is /// on while there is one. pub fn set_swipe_back(mtm: MainThreadMarker, path: &str) { BACK.set((!path.is_empty()).then(|| path.to_string())); @@ -333,8 +397,10 @@ pub fn set_swipe_back(mtm: MainThreadMarker, path: &str) { let this = SwipeBack::alloc(mtm).set_ivars(()); unsafe { msg_send![super(this), init] } }; - // A pan that only begins at the edge (`gestureRecognizerShouldBegin:`), rather than - // UIKit's edge recognizer, which ignores the touches XCTest makes up. + // A pan that only begins heading right (`gestureRecognizerShouldBegin:`), from anywhere on + // the page as Slack's does, rather than UIKit's edge recognizer, which also ignores the + // touches XCTest makes up. Once it begins, the page's own touches are cancelled, so the + // list stops scrolling under it. // SAFETY: the target is kept alive beside the recognizer, and answers `pan:`. let swipe = unsafe { UIPanGestureRecognizer::initWithTarget_action( @@ -345,8 +411,6 @@ pub fn set_swipe_back(mtm: MainThreadMarker, path: &str) { }; swipe.setDelegate(Some(ProtocolObject::from_ref(&*target))); window.addGestureRecognizer(&swipe); - // Behind a page with nothing pictured under it. - window.setBackgroundColor(Some(&UIColor::blackColor())); SWIPE.set(Some((swipe, target))); } SWIPE.with_borrow(|swipe| { @@ -356,123 +420,190 @@ pub fn set_swipe_back(mtm: MainThreadMarker, path: &str) { }); } -/// The swipe began: the page it goes back to, pictured, slides in underneath. -fn begin_swipe(mtm: MainThreadMarker, page: &UIView) { +/// The swipe began: the page is pictured and that picture follows the finger, over the picture of +/// the page it goes back to. +fn begin_swipe(mtm: MainThreadMarker, window: &UIView, page: &UIView) { finish_pending(); - let Some(parent) = page.superview() else { + let Some(leaving) = picture(page) else { return; }; + let frame = page.frame(); let under = BACK .with_borrow(Clone::clone) - .and_then(|path| picture_of(&path)); - if let Some(under) = under { - under.setFrame(page.frame()); - under.setAlpha(1.0); - parent.insertSubview_belowSubview(&under, page); - let dim = dim_over(mtm, &under, DIM); - UNDER.set(Some((under, dim))); - } - shadow(page, true); + .and_then(|path| picture_of(&path)) + .unwrap_or_else(|| { + // Nothing pictured (the app opened on this page): a blank page, not a black hole. + let blank = UIView::initWithFrame(UIView::alloc(mtm), frame); + blank.setBackgroundColor(Some(&UIColor::systemBackgroundColor())); + blank + }); + under.setFrame(frame); + under.setAlpha(1.0); + under.setTransform(shifted(0.0)); + window.addSubview(&under); + let dim = dim_over(mtm, &under, DIM); + leaving.setFrame(frame); + window.addSubview(&leaving); + shadow(&leaving, true); + DRAG.set(Some(Drag { + leaving, + under, + dim, + })); } -/// The swipe moved the page `progress` of the way across. -fn follow(page: &UIView, moved: CGFloat, width: CGFloat) { - page.setTransform(shifted(moved)); +/// The finger moved the page `moved` points across. +fn follow(moved: CGFloat, width: CGFloat) { let progress = moved / width; - UNDER.with_borrow(|under| { - if let Some((under, dim)) = under { + DRAG.with_borrow(|drag| { + if let Some(Drag { + leaving, + under, + dim, + }) = drag + { + leaving.setTransform(shifted(moved)); under.setTransform(shifted(-width * PARALLAX * (1.0 - progress))); dim.setAlpha(DIM * (1.0 - progress)); } }); } -/// Let go short: the page returns, and what was under it goes. -fn settle(mtm: MainThreadMarker, page: Retained, width: CGFloat) { - let moving = page.clone(); - let under = UNDER.with_borrow(Clone::clone); - let moved_under = under.clone(); - animate( +/// A spring with no bounce that starts at the finger's own speed: `speed` points per second over +/// the `distance` still to go. +fn release( + mtm: MainThreadMarker, + speed: CGFloat, + distance: CGFloat, + moves: impl Fn() + 'static, + done: impl Fn() + 'static, +) { + MOVING.set(true); + let moves = RcBlock::new(moves); + let done = RcBlock::new(move |_: Bool| { + MOVING.set(false); + done(); + }); + let initial = if distance.abs() < 1.0 { + 0.0 + } else { + (speed / distance).clamp(0.0, 30.0) + }; + UIView::animateWithDuration_delay_usingSpringWithDamping_initialSpringVelocity_options_animations_completion( + RELEASE, + 0.0, + 1.0, + initial, + UIViewAnimationOptions::AllowUserInteraction | UIViewAnimationOptions::BeginFromCurrentState, + &moves, + Some(&done), mtm, - 0.3, + ); +} + +/// Let go short: the page returns, and the pictures go. +fn settle(mtm: MainThreadMarker, moved: CGFloat, velocity: CGFloat, width: CGFloat) { + let Some(drag) = DRAG.take() else { + return; + }; + let Drag { + leaving, + under, + dim, + } = drag; + let (moving, behind, dimming) = (leaving.clone(), under.clone(), dim.clone()); + release( + mtm, + -velocity, + moved, move || { moving.setTransform(shifted(0.0)); - if let Some((under, dim)) = &moved_under { - under.setTransform(shifted(-width * PARALLAX)); - dim.setAlpha(DIM); - } + behind.setTransform(shifted(-width * PARALLAX)); + dimming.setAlpha(DIM); }, move || { - shadow(&page, false); - if let Some((under, dim)) = &under { - dim.removeFromSuperview(); - under.removeFromSuperview(); - } - UNDER.set(None); + leaving.removeFromSuperview(); + dim.removeFromSuperview(); + under.removeFromSuperview(); }, ); } -/// Let go far or fast: the page slides off, and the app goes back. The picture of the page it -/// goes to covers the screen until the runtime has drawn it (`tree_applied`). -fn slide_off(mtm: MainThreadMarker, page: Retained, width: CGFloat) { - let moving = page.clone(); - let under = UNDER.take(); - let moved_under = under.clone(); - animate( +/// Let go far or fast: the app goes back at once, so the runtime draws where it goes while the +/// page slides off; the picture of that page covers the screen until it has (`tree_applied`). +fn slide_off( + mtm: MainThreadMarker, + window: &UIView, + moved: CGFloat, + velocity: CGFloat, + width: CGFloat, +) { + let Some(drag) = DRAG.take() else { + return; + }; + let Drag { + leaving, + under, + dim, + } = drag; + // The keyboard goes with the page it was typing in. + window.endEditing(true); + RETURNING.set(Some(Returning { + cover: under.clone(), + navigated: false, + drawn: false, + settled: false, + })); + reply(&serde_json::json!({ "back": true })); + let (moving, behind, dimming) = (leaving.clone(), under.clone(), dim.clone()); + release( mtm, - 0.25, + velocity, + width - moved, move || { moving.setTransform(shifted(width)); - if let Some((under, dim)) = &moved_under { - under.setTransform(shifted(0.0)); - dim.setAlpha(0.0); - } + behind.setTransform(shifted(0.0)); + dimming.setAlpha(0.0); }, move || { - shadow(&page, false); - if let Some((under, dim)) = &under { - dim.removeFromSuperview(); - if let Some(parent) = under.superview() { - parent.bringSubviewToFront(under); - } - SWIPED.set(Some(under.clone())); - } - page.setTransform(shifted(0.0)); - reply(&serde_json::json!({ "back": true })); - // Nothing pictured: the page comes back as soon as it moved. - if under.is_none() { - page.setAlpha(0.0); - let shown = page.clone(); - let fade_in = RcBlock::new(move || shown.setAlpha(1.0)); - if let Some(mtm) = MainThreadMarker::new() { - UIView::animateWithDuration_delay_options_animations_completion( - 0.18, - 0.06, - UIViewAnimationOptions::CurveEaseOut, - &fade_in, - None, - mtm, - ); + leaving.removeFromSuperview(); + dim.removeFromSuperview(); + RETURNING.with_borrow_mut(|returning| { + if let Some(returning) = returning { + returning.settled = true; } - } + }); + finish_return(false); + // A runtime that draws nothing new still lets the screen go. + later(WAIT, || finish_return(true)); }, ); } +/// The swipe's cover goes once the slide is at rest and the page under it drawn (or `anyway`). +fn finish_return(anyway: bool) { + let done = RETURNING.with_borrow(|returning| { + returning + .as_ref() + .is_some_and(|r| r.settled && (r.drawn || anyway)) + }); + if done && let Some(returning) = RETURNING.take() { + fade_out(returning.cover); + } +} + define_class!( // SAFETY: NSObject has no subclassing requirements; the class does not implement `Drop`. #[unsafe(super(NSObject))] #[thread_kind = MainThreadOnly] #[name = "HazelSwipeBack"] - /// Moves the page with the edge swipe, and goes back when it is let go far or fast enough. + /// Moves the page with the swipe, and goes back when it is let go far or fast enough. struct SwipeBack; unsafe impl NSObjectProtocol for SwipeBack {} unsafe impl UIGestureRecognizerDelegate for SwipeBack { - /// Only a drag from the left edge that heads right more than it climbs or falls, while - /// no slide is moving. + /// Only a drag that heads right more than it climbs or falls, while nothing slides. #[unsafe(method(gestureRecognizerShouldBegin:))] fn should_begin(&self, recognizer: &UIGestureRecognizer) -> Bool { let Some(swipe) = recognizer.downcast_ref::() else { @@ -481,16 +612,14 @@ define_class!( let Some(window) = window(self.mtm()) else { return Bool::NO; }; - let (at, moved) = ( - swipe.locationInView(Some(&window)), - swipe.translationInView(Some(&window)), - ); + let moved = swipe.translationInView(Some(&window)); let velocity = swipe.velocityInView(Some(&window)); + let rightward = |x: CGFloat, y: CGFloat| x > 0.0 && x > y.abs() * 1.2; Bool::new( !MOVING.get() - && at.x - moved.x <= EDGE - && velocity.x > 0.0 - && velocity.x > velocity.y.abs(), + && RETURNING.with_borrow(Option::is_none) + && DRAG.with_borrow(Option::is_none) + && (rightward(velocity.x, velocity.y) || rightward(moved.x, moved.y)), ) } } @@ -504,25 +633,26 @@ define_class!( }; let width = window.bounds().size.width; let moved = swipe.translationInView(Some(&window)).x.clamp(0.0, width); + let velocity = swipe.velocityInView(Some(&window)).x; // UIGestureRecognizerState: 1 began, 2 changed, 3 ended, 4 cancelled, 5 failed. let state: isize = unsafe { msg_send![swipe, state] }; match state { 1 => { - begin_swipe(mtm, &page); - follow(&page, moved, width); + begin_swipe(mtm, &window, &page); + follow(moved, width); } - 2 => follow(&page, moved, width), + 2 => follow(moved, width), 3 => { - let velocity = swipe.velocityInView(Some(&window)).x; + let projected = moved + velocity * PROJECTION; let back = velocity > COMMIT_VELOCITY - || (moved > width * COMMIT_SHARE && velocity > -COMMIT_VELOCITY); + || (projected > width * COMMIT_SHARE && velocity > -COMMIT_VELOCITY / 2.0); if back { - slide_off(mtm, page, width); + slide_off(mtm, &window, moved, velocity, width); } else { - settle(mtm, page, width); + settle(mtm, moved, velocity, width); } } - _ => settle(mtm, page, width), + _ => settle(mtm, moved, 0.0, width), } } } From 8b2d989d86d7c21687faf365d3688a100585c3e9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 19:14:19 +0100 Subject: [PATCH 2/2] perf: a Message costs microseconds, not milliseconds (tabs and channels 4-5x faster on iOS) Opening a channel on iOS took 125-145 ms from the tap to its tree, the Chats tab 80-145 ms: a page's burst of collection updates landed one Message every ~3 ms, and nearly all of that was the app's 216 Subscriptions re-deriving their dependencies after each one. - `pageHostOf`/`sharedOf` are cached per Model (it never changes in place). Every page Subscription read the page host on every Message, each rebuilding `Shared` and resolving the keymap: 216 times per Message. - Foldkit (patch): one fiber follows the Model and derives every Subscription's dependencies in a single pass, handing them only to the Subscriptions whose dependencies changed, instead of one Model subscriber per Subscription waking all 216 fibers on every Message. - The iOS worker has a real `setImmediate` (a MessageChannel), installed as the bundle's banner so Effect sees it when its scheduler loads: `setTimeout(f, 0)` is clamped to 4 ms there. - `NATIVE_STATS` works on iOS (commit stats and the host's tree timings go to the log), and `NATIVE_DEBUG` logs how long each update took. Tap to tree, iPhone 17 simulator, offline fixture: channel 125-145 -> 22-31 ms, Chats 80-145 -> 22-27 ms, Customers 40-70 -> 9-17 ms; the per-Message dependency pass 2.6 -> 0.3 ms. Co-Authored-By: Claude Opus 5.5 --- apps/native/host/ios/src/bridge.rs | 21 ++++++ apps/native/scripts/build-ios.ts | 13 ++++ apps/native/scripts/ios.ts | 1 + apps/native/src/bun-plugin.d.ts | 16 +++-- apps/native/src/ios-immediate.ts | 35 ++++++++++ apps/native/src/ios.ts | 8 +-- apps/native/src/runtime.ts | 8 ++- apps/web-foldkit/src/app/model.ts | 25 ++++++- patches/foldkit@0.170.0.patch | 101 ++++++++++++++++++++++++++++- 9 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 apps/native/src/ios-immediate.ts diff --git a/apps/native/host/ios/src/bridge.rs b/apps/native/host/ios/src/bridge.rs index 2734c0e19..66124137c 100644 --- a/apps/native/host/ios/src/bridge.rs +++ b/apps/native/host/ios/src/bridge.rs @@ -3,6 +3,7 @@ //! Lines the worker posts are the render protocol the macOS app reads from its Bun child; the //! host's lines go back through `outbox`, each batch evaluated in the page on the main thread. use std::cell::{Cell, RefCell}; +use std::collections::HashMap; use std::io::Write; use std::sync::{Mutex, OnceLock}; @@ -72,6 +73,18 @@ pub fn environment(name: &str) -> Option { Some(bundled.get(name)?.as_str()?.to_string()) } +/// Whether a debug setting is on; read once. +fn environment_flag(name: &'static str) -> bool { + thread_local! { + static FLAGS: RefCell> = RefCell::new(HashMap::new()); + } + FLAGS.with_borrow_mut(|flags| { + *flags + .entry(name) + .or_insert_with(|| environment(name).is_some()) + }) +} + /// Each frame's poll; the first one, with the window up, starts the runtime. pub fn poll(s: &mut State, at: f64, tx: &mut rune::Tx) -> bool { if !STARTED.get() { @@ -166,6 +179,7 @@ fn evaluate(web: &WebView, script: &str) { /// One line the worker posted: a tree or a request for Rune, as the macOS app handles its child's. fn receive(line: &str) { + let started = std::time::Instant::now(); let message = match serde_json::from_str::(line) { Ok(message) => message, Err(error) => return log(&format!("[host] bad message: {error}")), @@ -178,6 +192,13 @@ fn receive(line: &str) { Routed::Window(message) => { let is_tree = matches!(message, RuntimeMsg::Tree(_)); shell::apply(message, &handle); + if is_tree && environment_flag("NATIVE_STATS") { + log(&format!( + "[host] tree of {} KB parsed and applied in {:.1} ms", + line.len() / 1024, + started.elapsed().as_secs_f64() * 1000.0 + )); + } // A navigation waiting on the next page moves once it is drawn. if is_tree { crate::system::tree_applied(); diff --git a/apps/native/scripts/build-ios.ts b/apps/native/scripts/build-ios.ts index d44682b30..0cdb9345b 100644 --- a/apps/native/scripts/build-ios.ts +++ b/apps/native/scripts/build-ios.ts @@ -12,7 +12,20 @@ const stylesPath = new URL("../dist/styles.json", import.meta.url).pathname // an earlier build lacks every class added since. await import("./precompile-styles") +// `setImmediate` runs before every module (`src/ios-immediate.ts`): Effect picks its scheduler as +// its module loads, and the bundler orders modules its own way, so an import cannot go first. +const immediate = await Bun.build({ + entrypoints: [new URL("../src/ios-immediate.ts", import.meta.url).pathname], + target: "browser", + // Its own scope: its names must not meet the bundle's. + format: "iife", + minify: true, +}) +if (!immediate.success) throw new AggregateError(immediate.logs, "src/ios-immediate.ts did not build") +const banner = await immediate.outputs[0]!.text() + const result = await Bun.build({ + banner, entrypoints: [new URL("../src/ios.ts", import.meta.url).pathname], outdir: new URL("../dist/ios", import.meta.url).pathname, naming: "runtime.js", diff --git a/apps/native/scripts/ios.ts b/apps/native/scripts/ios.ts index d41b57264..1fa1b9785 100644 --- a/apps/native/scripts/ios.ts +++ b/apps/native/scripts/ios.ts @@ -60,6 +60,7 @@ const environment: Record = release "VITE_BACKEND_URL", "VITE_APP_URL", "NATIVE_DEBUG", + "NATIVE_STATS", "NATIVE_PATH", "HAZEL_ISLANDS", "HAZEL_FIXTURE_HTML", diff --git a/apps/native/src/bun-plugin.d.ts b/apps/native/src/bun-plugin.d.ts index 47098dd62..646cc2139 100644 --- a/apps/native/src/bun-plugin.d.ts +++ b/apps/native/src/bun-plugin.d.ts @@ -19,12 +19,20 @@ declare const Bun: { target?: "bun" | "browser" /** One executable with Bun inside (scripts/bundle.ts). */ compile?: { outfile: string; target?: string } - format?: "esm" + /** `iife`: code that must keep its names to itself (the iOS worker's banner). */ + format?: "esm" | "iife" + /** Code put before the bundle's, unchanged. */ + banner?: string bytecode?: boolean minify: boolean /** Expressions replaced at build time, each by the code given. */ define?: Record - external: Array - plugins: Array - }): Promise<{ success: boolean; logs: Array }> + external?: Array + plugins?: Array + }): Promise<{ + success: boolean + logs: Array + /** What was built, when nothing was written (no `outdir`). */ + outputs: Array<{ text(): Promise }> + }> } diff --git a/apps/native/src/ios-immediate.ts b/apps/native/src/ios-immediate.ts new file mode 100644 index 000000000..c8b5ad797 --- /dev/null +++ b/apps/native/src/ios-immediate.ts @@ -0,0 +1,35 @@ +/** + * `setImmediate` for the iOS worker, as Bun has it: a macrotask with no delay. Effect's scheduler + * picks `setImmediate` when it is there and `setTimeout(f, 0)` otherwise, which WebKit clamps to + * 4 ms once timeouts nest five deep: every Message the runtime handled cost ~3-4 ms of waiting, a + * tab's burst of collection updates 60-120 ms. A `MessageChannel` message is never clamped. + * + * The bundle's banner (`scripts/build-ios.ts`), so it is installed before any module loads. + */ + +type Task = { readonly callback: (...args: Array) => void; readonly args: Array } + +const tasks = new Map() +let nextId = 0 +const channel = new MessageChannel() +channel.port1.onmessage = (event: MessageEvent) => { + const task = tasks.get(event.data) + if (task === undefined) return + tasks.delete(event.data) + task.callback(...task.args) +} + +const setImmediate = (callback: (...args: Array) => void, ...args: Array) => { + const id = ++nextId + tasks.set(id, { callback, args }) + channel.port2.postMessage(id) + return id +} +const clearImmediate = (id: number) => { + tasks.delete(id) +} + +Object.assign(globalThis, { setImmediate, clearImmediate }) + +// A module, so its names stay its own. +export {} diff --git a/apps/native/src/ios.ts b/apps/native/src/ios.ts index f1541f8eb..5d0f33392 100644 --- a/apps/native/src/ios.ts +++ b/apps/native/src/ios.ts @@ -102,12 +102,6 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { return new Response(empty ? null : reply.body, { status: reply.status, headers: reply.headers }) } -// Bun's, which some of the app's dependencies schedule with. -Object.assign(globalThis, { - setImmediate: (callback: (...args: Array) => void, ...args: Array) => - setTimeout(callback, 0, ...args), - clearImmediate: (handle: ReturnType) => clearTimeout(handle), -}) // The runtime and the web app read their settings from `process.env`, as under Bun. Object.assign(globalThis, { process: { env: { ...__HAZEL_ENV__ }, platform: "ios" } }) const log = (line: string) => request({ log: line }) @@ -152,6 +146,8 @@ const platform: NativePlatform = { request(isSignIn ? { signIn: href, callbackScheme: CALLBACK_SCHEME } : { open: href }) }, copyText: (text) => request({ copy: text }), + // No files from the worker: debug lines (`NATIVE_STATS`) go to the log, tagged with their file. + appendLine: (path, line) => log(`[${path}] ${line}`), onNavigate: (from, to) => { const back = backTargets.at(-1) const isBack = back !== undefined && pathOf(back.href) === pathOf(to) diff --git a/apps/native/src/runtime.ts b/apps/native/src/runtime.ts index 5b7f7642b..9db1b28a2 100644 --- a/apps/native/src/runtime.ts +++ b/apps/native/src/runtime.ts @@ -252,8 +252,12 @@ export const startRuntime = async (platform: NativePlatform) => { tags.includes("GotListMessage") || tags.some((t) => t.startsWith("Failed")) ? ` ${JSON.stringify(leaf).slice(0, 600)}` : "" - debug(`[msg] ${performance.now().toFixed(0)} ${tags.join("/")}${detail}`) - return app.update(model, message) + const startedAt = performance.now() + const result = app.update(model, message) + // When it started, and how long its update took. + const took = (performance.now() - startedAt).toFixed(1) + debug(`[msg] ${startedAt.toFixed(0)} +${took}ms ${tags.join("/")}${detail}`) + return result }, view: app.view, subscriptions: app.subscriptions, diff --git a/apps/web-foldkit/src/app/model.ts b/apps/web-foldkit/src/app/model.ts index 39819db56..31fca451b 100644 --- a/apps/web-foldkit/src/app/model.ts +++ b/apps/web-foldkit/src/app/model.ts @@ -148,7 +148,22 @@ export const memberOf = (model: Model): Member | null => ), ) -export const sharedOf = (model: Model): Shared => ({ +/** + * Per Model: the Model never changes in place, and every page Subscription reads the page host on + * every Message (hundreds of reads of the same Model, each resolving the keymap). + */ +const sharedCache = new WeakMap() +const pageHostCache = new WeakMap() + +export const sharedOf = (model: Model): Shared => { + const cached = sharedCache.get(model) + if (cached !== undefined) return cached + const shared = computeShared(model) + sharedCache.set(model, shared) + return shared +} + +const computeShared = (model: Model): Shared => ({ auth: authOf(model.session), orgSlug: orgSlugOf(model.route) ?? null, currentUser: currentUserOf(model), @@ -165,7 +180,13 @@ export const sharedOf = (model: Model): Shared => ({ isOverlayOpen: model.modal.current !== null || model.commandPalette.isOpen, }) -export const pageHostOf = (model: Model): PageHost => ({ page: model.page, shared: sharedOf(model) }) +export const pageHostOf = (model: Model): PageHost => { + const cached = pageHostCache.get(model) + if (cached !== undefined) return cached + const host = { page: model.page, shared: sharedOf(model) } + pageHostCache.set(model, host) + return host +} export const shellContextOf = (model: Model): ShellUpdateContext => ({ orgSlug: orgSlugOf(model.route) ?? null, diff --git a/patches/foldkit@0.170.0.patch b/patches/foldkit@0.170.0.patch index 7909106c9..7f4936ca6 100644 --- a/patches/foldkit@0.170.0.patch +++ b/patches/foldkit@0.170.0.patch @@ -985,8 +985,107 @@ index a26ae8ea901f2dc0af1a6977ade3ddc12df85b99..47c599347dcdda8c59755a610ea88628 register: id => { dialogIds.add(id); }, +diff --git a/dist/runtime/subscriptionFibers.js b/dist/runtime/subscriptionFibers.js +index 8984e250b228da2774aec037862856290813eeae..6d93873d4ebb9e6ac39fd241af18cfc144d09ee8 100644 +--- a/dist/runtime/subscriptionFibers.js ++++ b/dist/runtime/subscriptionFibers.js +@@ -1,33 +1,19 @@ +-import { Effect, Option, PubSub, Record, Ref, Schema, Scope, Stream, pipe, } from 'effect'; ++import { Effect, Option, PubSub, Queue, Record, Schema, Scope, Stream, pipe, } from 'effect'; + import { measureSlowPhase, reportSlowPhase, } from './slowPhase.js'; + /** +- * Forks one fiber per Subscription into the runtime scope. Each fiber +- * derives the Subscription's dependencies from every Model, restarts its +- * stream when they change under its equivalence, and feeds the Messages +- * the stream emits into the queue. A defect anywhere in that pipeline +- * crashes the runtime instead of dying silently in the fiber. ++ * Forks one fiber per Subscription into the runtime scope, and one fiber ++ * that follows the Model for all of them. On every Model that one fiber ++ * derives each Subscription's dependencies in a single synchronous pass and ++ * hands them on only to the Subscriptions whose dependencies changed under ++ * their equivalence; each Subscription's fiber restarts its stream on what ++ * it is handed and feeds the Messages the stream emits into the queue. (One ++ * Model subscriber per Subscription woke every fiber on every Message: with ++ * hundreds of Subscriptions that cost milliseconds per Message.) A defect ++ * anywhere in that pipeline crashes the runtime instead of dying silently in ++ * the fiber. + */ +-export const forkSubscriptionFibers = ({ subscriptions, initModel, modelPubSub, runtimeScope, maybeSlowSubscriptionDependencies, enqueueMessageEffect, provideAllResources, crashWith, }) => pipe(subscriptions, Record.toEntries, Effect.forEach(([key, { dependenciesSchema, modelToDependencies, keepAliveEquivalence, dependenciesToStream, },]) => Effect.gen(function* () { +- const equivalence = keepAliveEquivalence ?? Schema.toEquivalence(dependenciesSchema); +- const [initDependencies, maybeInitDependenciesDuration] = measureSlowPhase(maybeSlowSubscriptionDependencies, () => modelToDependencies(initModel)); +- reportSlowPhase(maybeSlowSubscriptionDependencies, maybeInitDependenciesDuration, (durationMs, thresholdMs) => ({ +- _tag: 'SubscriptionDependencies', +- subscriptionKey: key, +- model: initModel, +- durationMs, +- thresholdMs, +- })); +- const latestDependenciesRef = yield* Ref.make(initDependencies); +- const modelSubscription = yield* PubSub.subscribe(modelPubSub).pipe(Effect.provideService(Scope.Scope, runtimeScope)); +- const modelChangesStream = Stream.fromSubscription(modelSubscription).pipe( +- // NOTE: Ref.set runs upstream of Stream.changesWith on +- // every model change, so readDependencies() returns +- // current values even when the equivalence filter +- // doesn't emit. Moving this into a tap after +- // changesWith would silently break subscribers whose +- // dependencies are equivalence-stable across model +- // changes. +- Stream.mapEffect(model => Effect.gen(function* () { ++export const forkSubscriptionFibers = ({ subscriptions, initModel, modelPubSub, runtimeScope, maybeSlowSubscriptionDependencies, enqueueMessageEffect, provideAllResources, crashWith, }) => Effect.gen(function* () { ++ const deriveDependencies = (key, modelToDependencies, model) => { + const [dependencies, maybeDependenciesDuration] = measureSlowPhase(maybeSlowSubscriptionDependencies, () => modelToDependencies(model)); + reportSlowPhase(maybeSlowSubscriptionDependencies, maybeDependenciesDuration, (durationMs, thresholdMs) => ({ + _tag: 'SubscriptionDependencies', +@@ -36,13 +22,38 @@ export const forkSubscriptionFibers = ({ subscriptions, initModel, modelPubSub, + durationMs, + thresholdMs, + })); +- yield* Ref.set(latestDependenciesRef, dependencies); + return dependencies; +- }))); +- yield* Effect.forkIn(runtimeScope)(Stream.concat(Stream.make(initDependencies), modelChangesStream).pipe(Stream.changesWith(equivalence), Stream.switchMap(dependencies => dependenciesToStream(dependencies, () => Ref.getUnsafe(latestDependenciesRef))), Stream.runForEach(message => +- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */ +- enqueueMessageEffect(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none())))); +-}), { +- concurrency: 'unbounded', +- discard: true, +-})); ++ }; ++ const entries = yield* Effect.forEach(Record.toEntries(subscriptions), ([key, { dependenciesSchema, modelToDependencies, keepAliveEquivalence, dependenciesToStream, },]) => Effect.gen(function* () { ++ const equivalence = keepAliveEquivalence ?? Schema.toEquivalence(dependenciesSchema); ++ const initDependencies = deriveDependencies(key, modelToDependencies, initModel); ++ const changes = yield* Queue.unbounded(); ++ // NOTE: `latest` is set on every Model, so readDependencies() returns ++ // current values even when the equivalence holds and nothing is handed ++ // on; `emitted` is what the stream last restarted on, which a new ++ // value is compared against, as `Stream.changesWith` compared it. ++ const entry = { ++ key, ++ modelToDependencies, ++ equivalence, ++ changes, ++ latest: initDependencies, ++ emitted: initDependencies, ++ }; ++ yield* Effect.forkIn(runtimeScope)(Stream.concat(Stream.make(initDependencies), Stream.fromQueue(changes)).pipe(Stream.switchMap(dependencies => dependenciesToStream(dependencies, () => entry.latest)), Stream.runForEach(message => ++ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */ ++ enqueueMessageEffect(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none())))); ++ return entry; ++ }), { discard: false }); ++ const modelSubscription = yield* PubSub.subscribe(modelPubSub).pipe(Effect.provideService(Scope.Scope, runtimeScope)); ++ yield* Effect.forkIn(runtimeScope)(Stream.fromSubscription(modelSubscription).pipe(Stream.runForEach(model => Effect.sync(() => { ++ for (const entry of entries) { ++ const dependencies = deriveDependencies(entry.key, entry.modelToDependencies, model); ++ entry.latest = dependencies; ++ if (!entry.equivalence(entry.emitted, dependencies)) { ++ entry.emitted = dependencies; ++ Queue.offerUnsafe(entry.changes, dependencies); ++ } ++ } ++ })), Effect.catchCause(cause => crashWith(cause, Option.none())))); ++}); diff --git a/dist/snabbdom/init.js b/dist/snabbdom/init.js -index c6cf757..aa1246d 100644 +index c6cf75791ab75e3f98231b41e11e8bbca26f551c..aa1246d50373de7fb245285da2b13725d7304600 100644 --- a/dist/snabbdom/init.js +++ b/dist/snabbdom/init.js @@ -43,6 +43,16 @@ function createKeyToPreviousIndex(children, beginIndex, endIndex) {