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
- Never leave GPUI. Every closure gets the real
&mut Windowand&mut App. Components compile to ordinaryRenderOncestructs andRenderentities. Actions, focus,spawn,subscribeand.cached()all work unchanged. - No hidden thread-local runtime. Creating, reading and writing a signal takes
cx, like any GPUI call. - 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. - 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 aCopyhandle (a generational ID) that resolves to anEntity<T>.getandwithcallentity.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.setandupdatecallentity.update, thennotify.memoandeffectneed their own dependency tracking, because GPUI'sdetect_accessed_entitiesispub(crate). Either use a Rake-side observer stack insideSignal::get, or carry a smallgpui-cepatch 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
Setupparameter → stateless. It lowers to a plain#[derive(IntoElement)]+RenderOncestruct, with zero runtime cost. Signal reads inside it are tracked by the enclosing view. - A
Setupparameter → stateful. It lowers to aRenderOnceshell plus oneComponentViewentity, created throughwindow.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>andMemo<T>implementIntoElement, so{count}works in anyview!, including plain GPUI views.- Attribute values go through a
Value<T>trait implemented forTandSignal<T>, sodisabled={is_busy}works. For a plainTit 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 intoview!orcx.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]. intois 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: SharedStringmakescityaMemo<SharedString>in setup that follows the parent, somemo,effectandresourcecan react to it. In render, every prop is its current value either way.
Events
on:clickworks 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| …andmove |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, viaprovide_globalandcx.global_signal::<T>(). - Subtree:
cx.provide(value)andcx.context::<T>(). At mount, each stateful component recordswindow.current_view()as its parent in a Rake owner tree, andcontextwalks 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-ceis patched to record parents for every view.
Async
cx.spawnin setup is owned by the component. The task stays alive until unmount and is cancelled then. Dropping aTaskby accident can't silently cancel work.- A
setfrom 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 onAsyncAction). - 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:
readreturnsLoading, butlatestkeeps the last successful value andis_loadingreports 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_spawninside 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_cleanupruns 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 rootclassgives a size that doesn't depend on content (fixedw-*/h-*,size-full, percentages,flex-1with stretch). Override with#[rake::component(cached = false)]orcached = "…". - Phase 2 (follow-up): a
gpui-cepatch 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:
Renderviews with state in struct fields. - B.
#[rake::component]with signals. - C. Raw
window.use_keyed_stateinsideRenderOnce. B is built on C, so this shows Rake's own overhead.
| Scenario | What it catches |
|---|---|
| Mount 1, 100, 1k and 10k instances | Entity creation, per-instance memory |
| Re-render a parent with N unchanged children | Prop swapping, and caching paying off |
| Update one leaf's signal among N siblings | Whether the change stays local (with and without caching) |
| Update every component in one frame | Worst-case notify and redraw |
| One view reading 1, 10 and 50 signals | Per-read cost |
| One signal read by 1, 100 and 1k views | Fan-out cost |
| Memo chain of depth 1, 10 and 50 | Dependency tracking cost |
| Keyed list: reorder, insert, remove | State identity and cleanup |
| Mount/unmount in a loop | Leaks: entities, subscriptions, tasks, effect cleanups |
cx.spawn setting signals, unmount mid-flight | Scheduling overhead, late sets after unmount |
| Stateful component with children, re-rendering alone | Cost 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-request | Late 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
- Spike (report only, no merge):
- signals over
Entity, with GPUI's read tracking for views; - observer stack versus a
gpui-cepatch formemo/effect; - entity per signal versus in-view storage;
- the
ComponentViewlowering and props swap; - re-runnable children;
- whether two sibling
RenderOnceinstances sharewindow.use_state(open question from the original issue).
- signals over
- MVP behind
experimental-reactive:Signal,Setup::signal,#[rake::component](stateless and stateful), builder props,on:events;cx.spawnownership;- the setup-read compile error;
- the core tests.
memo,effect,on_cleanup,#[prop(track)]andcx.resource, automatic caching (phase 1), and the headless benchmark suite.- Context (
provide/context/global_signal), the Blaze setup-hash remount, inspector metadata. - Follow-ups, as separate issues: content-sized caching patch,
cx.actionfor mutations,bind:value(with the text-input work), real-window benchmarks on macOS and Linux.
Done when
rake::reactiveand#[rake::component]exist behindexperimental-reactive, withSignal,Memo,effect,on_cleanup,spawn,resource,provideandcontext, 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, keepslatestwhile refetching, retries after an error, and ignores late results.
- The benchmark suite runs, and its report is committed.
docs/experimental-view.mdis updated:- the "no state runtime" rule is replaced;
- when to use signals versus
Entityfields; - unmount rules;
- caching;
- a short side-by-side for React developers.
Activity
5@theobiabo yep you can do it :) just keep me updated, ty
Okay, thank you. You can assign the issue to me.
@theobiabo done!
Thank you.
Hey @1bye, I'd love to take on this issue if you don't have any work in the pipeline for it.