#27

`#[rake::component]`: function components with signals, benchmarked against plain GPUI

Open1byeopened 5d ago

Rewrite of #27. The original proposal used React-style hooks. After design discussion we're switching to signals: they map directly onto GPUI's entity model, so they cost less and need no rules of hooks. The goal and the benchmark requirement are unchanged.

Problem

Rake has no lightweight way to give a component its own state. Today, a component either stores every piece of UI state as a field on a parent Render view, or becomes a full Entity view with its own struct, constructor and cx.new call. For people coming from Electron and React, this is the biggest gap between how they're used to writing UI and how Rake works.

This issue adds function components with signals as a thin layer over GPUI, behind an experimental feature, and requires benchmarks against plain GPUI before anything ships.

This deliberately changes a rule in docs/experimental-view.md, which currently says there is "no global component cache or additional state runtime". The new runtime is opt-in and small, and every cost goes through an explicit cx call.

Design rules

  1. Never leave GPUI. Every closure gets the real &mut Window and &mut App. Components compile to ordinary RenderOnce structs and Render entities. Actions, focus, spawn, subscribe and .cached() all work unchanged.
  2. No hidden thread-local runtime. Creating, reading and writing a signal takes cx, like any GPUI call.
  3. Do it at compile time where possible. The macro generates the struct and view you would write by hand: no Box<dyn> and no type erasure, except where noted below.
  4. Batching comes free. GPUI already merges notifies into the next frame. There is no batch() API.

Proposed API

#[rake::component]
fn Counter(label: SharedString, start: i32, cx: &mut Setup) -> impl IntoElement {
    let count = cx.signal(start);                        // setup: runs once at mount
    let doubled = cx.memo(move |cx| count.get(cx) * 2);
    cx.effect(move |_, cx| log::info!("count = {}", count.get(cx)));

    rake::view! {                                        // final expression: render, runs every frame
        <button on:click={move |cx| count.update(cx, |n| *n += 1)}>
            {label} ": " {count} " (" {doubled} ")"
        </button>
    }
}

// usage
<Counter id="clicks" label="Clicks" start={0}/>

Public surface (rake::reactive)

#[derive(Clone, Copy)] pub struct Signal<T>;   // read + write
#[derive(Clone, Copy)] pub struct Memo<T>;     // derived, read-only

impl<T: 'static> Signal<T> {
    pub fn new(cx: &mut App, value: T) -> Self;
    pub fn read(self, cx: &App) -> &T;                            // tracked borrow, like entity.read
    pub fn get(self, cx: &App) -> T where T: Clone;               // tracked read
    pub fn with<R>(self, cx: &App, f: impl FnOnce(&T) -> R) -> R; // tracked borrow, closure form
    pub fn set<C: AppContext>(self, cx: &mut C, value: T) -> C::Result<()>;
    pub fn set_if_changed<C: AppContext>(self, cx: &mut C, value: T) -> C::Result<()> where T: PartialEq;
    pub fn update<C: AppContext, R>(self, cx: &mut C, f: impl FnOnce(&mut T) -> R) -> C::Result<R>;
    pub fn entity(self, cx: &App) -> Entity<T>;                   // escape hatch
}
impl<T: 'static> From<Entity<T>> for Signal<T> { … }

pub struct Setup<'a, V>;   // Deref + DerefMut to gpui::Context<'a, V>
impl Setup<'_, V> {
    pub fn signal<T>(&mut self, value: T) -> Signal<T>;
    pub fn memo<T: PartialEq>(&mut self, f: impl Fn(&App) -> T) -> Memo<T>;
    pub fn effect(&mut self, f: impl FnMut(&mut Window, &mut App));
    pub fn on_cleanup(&mut self, f: impl FnOnce(&mut App));
    pub fn provide<T: Clone + 'static>(&mut self, value: T);
    pub fn context<T: Clone + 'static>(&mut self) -> Option<T>;
    pub fn spawn<R>(&mut self, f: impl AsyncFnOnce(&mut AsyncApp) -> R);   // owned by the component
    pub fn resource<T, E, F>(&mut self, f: impl Fn(&App) -> F) -> Resource<T, E>
    where F: Future<Output = Result<T, E>> + 'static, E: Error + 'static;
}

#[derive(Clone, Copy)] pub struct Resource<T, E>;
impl<T: 'static, E: Error + 'static> Resource<T, E> {
    pub fn read(self, cx: &App) -> &LoadStatus<T, E>;   // tracked; the existing rake::loading type
    pub fn latest(self, cx: &App) -> Option<&T>;        // last successful value, kept while refetching
    pub fn is_loading(self, cx: &App) -> bool;
    pub fn retry<C: AppContext>(self, cx: &mut C) -> C::Result<()>;
}

pub fn provide_global<T: 'static>(cx: &mut App, value: T) -> Signal<T>;
// + cx.global_signal::<T>() -> Signal<T>

set and update work from App, Context<V> and AsyncApp. With AsyncApp they return Result if the app is gone. On a disposed signal they do nothing.

How it works

Signals

  • Signal<T> is a Copy handle (a generational ID) that resolves to an Entity<T>.
  • get and with call entity.read(cx). GPUI already records every entity read during a frame (accessed_entities), and its invalidator marks the views that read an entity as dirty when it notifies. Read tracking for views costs nothing extra.
  • set and update call entity.update, then notify.
  • memo and effect need their own dependency tracking, because GPUI's detect_accessed_entities is pub(crate). Either use a Rake-side observer stack inside Signal::get, or carry a small gpui-ce patch that exposes it. The spike decides which.
  • If one entity per local signal is measurably expensive, store local signals inside the component's view instead. That's an internal change; the API stays the same.

Components: RenderOnce or view

The macro decides from the signature:

  • No Setup parameter → stateless. It lowers to a plain #[derive(IntoElement)] + RenderOnce struct, with zero runtime cost. Signal reads inside it are tracked by the enclosing view.
  • A Setup parameter → stateful. It lowers to a RenderOnce shell plus one ComponentView entity, created through window.use_keyed_state(id, …). On later parent renders, only the props are swapped in.
  • #[rake::component(view)] forces a view boundary without state.

In a stateful component, the statements are setup (run once at mount) and the final expression is render. The macro wraps the final expression in the render closure and rebinds prop names to their current values. Only view! is used, with no separate render! macro.

The generated view is generic over the render closure type, so nothing is boxed:

impl RenderOnce for Counter {
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        let view = window.use_keyed_state(self.id.clone(), cx, |w, cx| {
            rake::reactive::ComponentView::new(&self, w, cx, __counter_setup)
        });
        view.update(cx, |v, cx| v.swap_props(self, cx));
        view
    }
}

Signals in markup

  • Signal<T: Display> and Memo<T> implement IntoElement, so {count} works in any view!, including plain GPUI views.
  • Attribute values go through a Value<T> trait implemented for T and Signal<T>, so disabled={is_busy} works. For a plain T it inlines away.
  • Reading a signal at the top level of setup, outside a closure or memo, is a compile error: "reading a signal in setup is not reactive; move it into view! or cx.memo".

Props

view! always lowers component tags to a generated type-state builder (Counter::builder().label(…).start(…).build()). This replaces today's struct-literal lowering for every component. Hand-written components add #[derive(rake::Props)].

#[rake::component]
fn Button(
    label: SharedString,                         // required
    #[prop(default)] variant: Variant,
    #[prop(default = px(8.))] padding: Pixels,
    icon: Option<IconName>,                      // Option<T>: optional
    disabled: bool,                              // bool: optional, `<Button disabled/>`
    #[event] click: ClickEvent,                  // enables on:click on <Button>
    #[rest] rest: Attrs,                         // forwards undeclared class/aria/on:* to the root
    children: Children,
) -> impl IntoElement { … }
  • A missing required prop is a compile error with a readable message, through #[diagnostic::on_unimplemented].
  • into is on by default (label="Save" works). Use #[prop(no_into)] to opt out.
  • A named props struct (fn Button(props: ButtonProps)) is supported for long or shared prop sets. {..base} still works.
  • Generic components (fn List<T: Clone + 'static>) carry their generics through to the builder.
  • Props that setup should follow: by default a prop in setup is its value at mount (right for start: i32). #[prop(track)] city: SharedString makes city a Memo<SharedString> in setup that follows the parent, so memo, effect and resource can react to it. In render, every prop is its current value either way.

Events

  • on:click works the same on built-in elements and on components. On a component it lowers to .on_click(…).
  • view! pads handlers with 1 or 2 parameters to GPUI's |event, window, cx| form at compile time: move |cx| … and move |ev, cx| … are both valid.

Children and slots

AnyElement can be used only once. In a stateless component that's fine. A stateful component can re-render without its parent, so its children and markup props (header={…}) lower to re-runnable closures, Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>. view! adds move and clones where it can. Values captured from the parent must be 'static.

Context

  • App-wide: GPUI globals that hold a Signal, via provide_global and cx.global_signal::<T>().
  • Subtree: cx.provide(value) and cx.context::<T>(). At mount, each stateful component records window.current_view() as its parent in a Rake owner tree, and context walks up that tree. The lookup happens once, in setup. Provide signals, so updates arrive through normal tracking.
  • Limitation: a hand-written GPUI view between provider and consumer breaks the chain unless it re-provides the value, or unless gpui-ce is patched to record parents for every view.

Async

  • cx.spawn in setup is owned by the component. The task stays alive until unmount and is cancelled then. Dropping a Task by accident can't silently cancel work.
  • A set from an async task after unmount does nothing.

Resources

cx.resource covers loading, errors and retry, built on the existing LoadState/LoadStatus in rake::loading:

#[rake::component]
fn Weather(#[prop(track)] city: SharedString, cx: &mut Setup) -> impl IntoElement {
    let weather = cx.resource(move |cx| {
        let city = city.get(cx);                    // tracked: a new city refetches
        async move { fetch_temp(city).await }       // Result<f32, FetchError>
    });

    rake::view! {
        <p>
            match weather.read(cx) {
                LoadStatus::Ready(t) => { {format!("{t:.1}°")} }
                LoadStatus::Failed(err) => {
                    <span class="text-red-500">{err.to_string()}</span>
                    <button on:click={move |cx| weather.retry(cx)}>"Retry"</button>
                }
                _ => { "Loading…" }
            }
        </p>
    }
}
  • Starts at mount. Work that should wait for a user action is a mutation: a separate cx.action (follow-up, built on AsyncAction).
  • Refetch: signal reads in the synchronous part of the closure, before the future is returned, are tracked. When one changes, the request in flight is cancelled and a new one starts. Reads inside the future are not tracked.
  • While refetching or retrying: read returns Loading, but latest keeps the last successful value and is_loading reports the request, so a UI can keep showing old data.
  • Cancellation: refetch, retry and unmount drop the task, and LoadState's generation token ignores results that arrive late.
  • Executor: the future runs on the foreground executor. Heavy work should call cx.background_spawn inside it.
  • No Suspense-style boundaries for now.

Effects

  • Effects run after the frame (on_next_frame). They run again when their dependencies change.
  • cx.on_cleanup runs on unmount, or before the effect runs again.

Unmount

GPUI drops element state that isn't used during a frame. A component unmounts when it isn't rendered in a frame: an untaken branch, a hidden tab, or a virtualized row scrolled off-screen. Its signals, effects, tasks and context entries are dropped with it. The docs must say this explicitly.

Caching

GPUI's entity.cached(style) skips render, layout and paint of a view that isn't dirty and replays last frame's output. The style exists because GPUI must size the box without rendering its content.

  • Phase 1 (this issue): the macro adds .cached(root_style) automatically when the stateful component's root class gives a size that doesn't depend on content (fixed w-*/h-*, size-full, percentages, flex-1 with stretch). Override with #[rake::component(cached = false)] or cached = "…".
  • Phase 2 (follow-up): a gpui-ce patch that caches content-sized views using last frame's measured size.

Without caching, a signal change still redraws from the dirty view's root ancestors. Signals make state local; caching is what makes the re-render local.

Blaze hot reload

The macro embeds a hash of the setup tokens. If a patch changes only the render expression, state is kept. If it changes setup, that component remounts.

Inspector

The macro emits a static table of component names and signal names, taken from the let bindings (count, doubled), so the Rake inspector and the CDP client can show each component and its signal values.

Benchmarks (required before merging)

Compare the same UI built three ways:

  • A. Plain GPUI: Render views with state in struct fields.
  • B. #[rake::component] with signals.
  • C. Raw window.use_keyed_state inside RenderOnce. B is built on C, so this shows Rake's own overhead.
ScenarioWhat it catches
Mount 1, 100, 1k and 10k instancesEntity creation, per-instance memory
Re-render a parent with N unchanged childrenProp swapping, and caching paying off
Update one leaf's signal among N siblingsWhether the change stays local (with and without caching)
Update every component in one frameWorst-case notify and redraw
One view reading 1, 10 and 50 signalsPer-read cost
One signal read by 1, 100 and 1k viewsFan-out cost
Memo chain of depth 1, 10 and 50Dependency tracking cost
Keyed list: reorder, insert, removeState identity and cleanup
Mount/unmount in a loopLeaks: entities, subscriptions, tasks, effect cleanups
cx.spawn setting signals, unmount mid-flightScheduling overhead, late sets after unmount
Stateful component with children, re-rendering aloneCost of the re-runnable children closure
Resource input changes rapidly (N times per frame)Refetch cost, cancellation, one result applied
Retry while a request is in flight; unmount mid-requestLate results ignored, no leaked tasks

Measure:

  • Frame time (render, layout, paint) at p50 and p95.
  • Allocations per frame and live memory after mount (counting allocator or dhat).
  • Entities, subscriptions and tasks alive after unmount: these must return to baseline.
  • Instruction counts (iai-callgrind or similar) if it runs in CI.

Thresholds:

  • Mount: report per-instance bytes and allocations against an agreed budget. B always creates an entity per stateful instance, so a percentage against A doesn't apply.
  • Update and re-render scenarios: B more than ~10% slower than A is a blocker to fix or explain.
  • Any leak: a blocker.

Setup: put the benchmarks next to crates/rake/benches/control_queries.rs, run them headless on TestAppContext, and commit the results to .agents/reports/ with the machine, build profile and gpui-ce revision.

Plan

  1. Spike (report only, no merge):
    • signals over Entity, with GPUI's read tracking for views;
    • observer stack versus a gpui-ce patch for memo/effect;
    • entity per signal versus in-view storage;
    • the ComponentView lowering and props swap;
    • re-runnable children;
    • whether two sibling RenderOnce instances share window.use_state (open question from the original issue).
  2. MVP behind experimental-reactive:
    • Signal, Setup::signal, #[rake::component] (stateless and stateful), builder props, on: events;
    • cx.spawn ownership;
    • the setup-read compile error;
    • the core tests.
  3. memo, effect, on_cleanup, #[prop(track)] and cx.resource, automatic caching (phase 1), and the headless benchmark suite.
  4. Context (provide/context/global_signal), the Blaze setup-hash remount, inspector metadata.
  5. Follow-ups, as separate issues: content-sized caching patch, cx.action for mutations, bind:value (with the text-input work), real-window benchmarks on macOS and Linux.

Done when

  • rake::reactive and #[rake::component] exist behind experimental-reactive, with Signal, Memo, effect, on_cleanup, spawn, resource, provide and context, and #[prop(track)].
  • view! lowers all component tags to builders; existing components migrate with #[derive(rake::Props)].
  • Tests show:
    • sibling instances don't share state;
    • a signal change re-renders only the views that read it, and a cached component skips render;
    • state survives a keyed reorder and a render-only Blaze patch, and a setup change remounts;
    • reading a signal in setup fails to compile;
    • tasks and effects are cancelled and cleaned up on unmount, with nothing leaked;
    • a stateful component with children re-renders on its own correctly;
    • a resource refetches when a tracked input or #[prop(track)] prop changes, keeps latest while refetching, retries after an error, and ignores late results.
  • The benchmark suite runs, and its report is committed.
  • docs/experimental-view.md is updated:
    • the "no state runtime" rule is replaced;
    • when to use signals versus Entity fields;
    • unmount rules;
    • caching;
    • a short side-by-side for React developers.

Activity

5
theobiabo4d ago

Hey @1bye, I'd love to take on this issue if you don't have any work in the pipeline for it.

1bye4d ago

@theobiabo yep you can do it :) just keep me updated, ty

theobiabo4d ago

Okay, thank you. You can assign the issue to me.

1bye4d ago
theobiabo4d ago

Thank you.