diff --git a/docs/architecture.md b/docs/architecture.md index 96bb042..1fdd63c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,6 +111,14 @@ src/ ├── os/ │ └── open.zig # Cross-platform URL opening │ +├── state/ # Reactive state management (MobX-inspired) +│ ├── mod.zig # Public exports +│ ├── tracker.zig # Dependency tracking context +│ ├── signal.zig # Observable state primitive +│ ├── computed.zig # Derived reactive values +│ ├── effect.zig # Side effect reactions +│ └── store.zig # Collections and transactions +│ └── ui/ ├── mod.zig # Public UI module exports ├── root.zig # UiRoot: component registry, dispatch @@ -236,9 +244,21 @@ struct { | Scene | `src/app/app_state.zig` | ViewMode, animation rects, focused session index | | UI | Component structs | Visibility flags, animation timers, cached textures | | Shared | `UiHost` | Read-only snapshot passed each frame | +| Reactive | `src/state/` | MobX-inspired signals, computeds, effects (prototype) | **Key rule**: Scene code must not own UI state; UI state lives inside components. +### Reactive State (Prototype) + +The `src/state/` module provides reactive primitives for future state management: + +- **Signal(T)**: Observable state with automatic change notifications +- **Computed(T)**: Derived values that track dependencies and auto-update +- **Effect**: Side effects that re-run when dependencies change +- **Transaction**: Batch updates for atomic state changes + +See `docs/state_management_refactor.md` for the migration plan. + ## Input Routing 1. SDL events enter `main.zig` diff --git a/docs/state_management_refactor.md b/docs/state_management_refactor.md new file mode 100644 index 0000000..cc7a1de --- /dev/null +++ b/docs/state_management_refactor.md @@ -0,0 +1,289 @@ +# State Management Refactoring Plan + +This document outlines the plan to migrate Architect's state management to a MobX-inspired reactive system using the new `src/state/` module. + +## Status Quo + +### Current Architecture + +The application currently uses manual state management scattered across several layers: + +1. **Application State (`src/app/app_state.zig`)** + - `ViewMode` enum (Grid, Expanding, Full, Collapsing, Panning*) + - `SessionStatus` enum (idle, running, awaiting_approval, done) + - `AnimationState` struct with interpolation logic + - No automatic dependency tracking or change notifications + +2. **Session State (`src/session/state.zig`)** + - Per-session data: PTY, terminal buffer, scroll position, CWD + - `dirty` flag for manual cache invalidation + - Direct field access without reactivity + +3. **UI Host Snapshot (`src/ui/types.zig`)** + - `UiHost`: read-only snapshot rebuilt every frame + - Manual synchronization between app state and UI + - `UiAction` queue for UI-to-app mutations + +4. **Main Loop (`src/main.zig`)** + - Central orchestration of state reads/writes + - Manual propagation of state changes + - Explicit dirty checking and cache invalidation + +### Current Pain Points + +- **Manual propagation**: State changes must be explicitly propagated through the call chain +- **Snapshot overhead**: `UiHost` is rebuilt every frame regardless of changes +- **Scattered mutations**: State modifications happen in multiple locations +- **No derived state**: Computed values (e.g., `isAnimating`, `canScroll`) are recalculated ad-hoc +- **Implicit dependencies**: Hard to trace which state a component depends on +- **Testing difficulty**: State interactions are hard to test in isolation + +## Objectives + +### Primary Goals + +1. **Introduce reactive primitives** without disrupting existing functionality +2. **Enable automatic dependency tracking** for UI components +3. **Reduce boilerplate** for state synchronization +4. **Improve testability** with isolated, observable state units +5. **Prepare foundation** for future features (undo/redo, persistence, debugging) + +### Non-Goals (This Phase) + +- Complete rewrite of existing state management +- Breaking changes to the public API +- Performance optimization (focus on correctness first) +- Persistence/serialization of reactive state + +## Technical Notes + +### New Module: `src/state/` + +The prototype introduces a MobX-inspired reactive state engine: + +``` +src/state/ +├── mod.zig # Public exports +├── tracker.zig # Dependency tracking context +├── signal.zig # Observable state primitive +├── computed.zig # Derived reactive values +├── effect.zig # Side effect reactions +└── store.zig # Collections and transactions +``` + +### Initialization and Lifecycle + +The reactive runtime requires a global dependency registry. Initialize it once at +startup with a long-lived allocator, and tear it down on shutdown: + +```zig +const state = @import("state/mod.zig"); + +state.init(allocator); +defer state.deinit(); +``` + +Without calling `state.init`, `Computed` and `Effect` will not auto-update when +dependencies change. Signals still notify direct subscribers, but dependency +tracking will be inactive. + +### Core Primitives + +#### Signal(T) +Observable state container that notifies subscribers on change: +```zig +var count = Signal(i32).init(allocator, 0); +defer count.deinit(); + +const value = count.get(); // Tracks dependency if in reactive context +count.set(42); // Notifies all subscribers +``` + +#### Computed(T) +Derived values that auto-update when dependencies change: +```zig +var doubled = Computed(i32).init(allocator, struct { + fn compute(_: *Computed(i32)) i32 { + return count.get() * 2; // Automatically tracks `count` + } +}.compute, null); +``` + +#### Effect +Side effects that re-run when dependencies change: +```zig +var logger = try Effect.init(allocator, struct { + fn run(_: ?*anyopaque) void { + std.debug.print("Count: {}\n", .{count.get()}); + } +}.run, null); +``` + +#### ComputedWithContext / EffectWithContext +Convenience wrappers accept a stable context pointer: +```zig +var app_state: AppState = .{}; +var title = ComputedWithContext([]const u8, AppState).init( + allocator, + AppState.computeTitle, + &app_state, +); +``` +The context must outlive the computed/effect. + +#### Batching +Group updates to minimize cascading reactions: +```zig +state.beginBatch(allocator); +count.set(1); +count.set(2); +count.set(3); +state.endBatch(); // Effects run once, not three times +``` + +### Migration Strategy + +The refactoring will proceed in phases to minimize risk: + +#### Phase 1: Parallel Introduction (Current) +- [x] Implement reactive primitives in `src/state/` +- [x] Add comprehensive tests for core functionality +- [x] Document API and patterns + +#### Phase 2: App State Migration +- [ ] Create `AppStore` wrapping `ViewMode`, `focused_session`, animation state +- [ ] Replace direct field access with signal reads +- [ ] Keep existing `UiHost` as a compatibility layer + +#### Phase 3: Session State Migration +- [ ] Create `SessionStore` for per-session reactive state +- [ ] Replace `dirty` flag with automatic invalidation +- [ ] Migrate scroll position, CWD, status to signals + +#### Phase 4: UI Component Migration +- [ ] Convert UI components to use reactive state +- [ ] Replace `UiHost` snapshot with direct signal access +- [ ] Remove manual `needsFrame()` checks where possible + +#### Phase 5: Cleanup and Optimization +- [ ] Remove obsolete synchronization code +- [ ] Profile and optimize hot paths +- [ ] Add derived state (computeds) for common patterns + +### Design Decisions + +1. **Explicit `.get()`/`.set()` API**: Unlike JavaScript's proxies, Zig requires explicit method calls. This is actually beneficial for clarity. + +2. **Thread-local tracking**: The tracker uses thread-local storage for the current context, enabling nested computations. + +3. **Allocator-aware**: All primitives accept an allocator, following Zig conventions for memory management. + +4. **Global registry for observers**: Signals keep local subscribers while the tracker maintains a global observer registry for computed/effect invalidation. + +5. **Batch semantics**: Batching defers notifications until the outermost batch ends, similar to MobX's `runInAction`. + +### Integration Points + +| Current Code | Reactive Equivalent | +|--------------|---------------------| +| `view_mode` variable | `Signal(ViewMode)` | +| `focused_session` variable | `Signal(usize)` | +| `session.dirty` flag | Automatic via signal subscription | +| `UiHost` snapshot | Computed or direct signal access | +| `UiAction` queue | Can coexist; actions trigger signal updates | +| `needsFrame()` | Effect that sets a frame-needed flag | + +### Compatibility Considerations + +- **Existing UI components**: Continue using `UiHost` initially; migrate incrementally +- **Renderer**: Can observe app state signals for automatic redraw triggers +- **Configuration**: Signals can wrap config values for reactive updates +- **Persistence**: Transaction API enables batched updates; rollback only cancels notifications (it does not restore prior values) + +### Threading Notes + +- Dependency tracking is thread-local. Reactive state should be read/written from a single thread unless a dedicated synchronization strategy is introduced. + +## Acceptance Criteria + +### Phase 1 (Prototype) - Current +- [x] `Signal(T)` supports get/set with change detection +- [x] `Signal(T)` notifies subscribers on change +- [x] `Computed(T)` tracks dependencies automatically +- [x] `Computed(T)` recomputes only when dependencies change +- [x] `Effect` runs immediately and on dependency changes +- [x] Batching defers notifications until batch ends +- [x] All primitives pass unit tests +- [x] Reactive registry initialized via `state.init(...)` in tests/examples +- [ ] Build succeeds with `zig build` +- [ ] Tests pass with `zig build test` + +### Phase 2 (App State) +- [ ] `AppStore` encapsulates view mode, focus, animation +- [ ] State changes trigger appropriate reactions +- [ ] No regression in existing functionality +- [ ] `UiHost` can be populated from signals + +### Phase 3 (Session State) +- [ ] `SessionStore` manages per-session reactive state +- [ ] Cache invalidation happens automatically +- [ ] Scroll, CWD, status are reactive +- [ ] Memory usage remains stable + +### Phase 4 (UI Components) +- [ ] At least one UI component uses direct signal access +- [ ] Frame requests driven by reactivity where appropriate +- [ ] Component tests verify reactive behavior + +### Phase 5 (Cleanup) +- [ ] Unused synchronization code removed +- [ ] Performance benchmarks show no regression +- [ ] Documentation updated for reactive patterns + +## Example: Migrating ViewMode + +### Before (Manual) +```zig +// main.zig +var view_mode: ViewMode = .Grid; + +// Later... +view_mode = .Expanding; +// Must manually trigger dependent updates +renderer.setNeedsRedraw(); +ui.invalidate(); +``` + +### After (Reactive) +```zig +// app_store.zig +pub const AppStore = struct { + view_mode: Signal(ViewMode), + + pub fn init(allocator: std.mem.Allocator) AppStore { + return .{ + .view_mode = Signal(ViewMode).init(allocator, .Grid), + }; + } +}; + +// main.zig +var app = AppStore.init(allocator); + +// Renderer subscribes to view_mode +try app.view_mode.subscribe(struct { + fn onViewModeChange(_: ?*anyopaque) void { + renderer.setNeedsRedraw(); + } +}.onViewModeChange, null); + +// Later... just set the value +app.view_mode.set(.Expanding); +// Renderer automatically notified +``` + +## References + +- [MobX Documentation](https://mobx.js.org/README.html) +- [Solid.js Reactivity](https://www.solidjs.com/guides/reactivity) +- [Vue 3 Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html) diff --git a/docs/stepcat_plan.md b/docs/stepcat_plan.md new file mode 100644 index 0000000..34c8d8a --- /dev/null +++ b/docs/stepcat_plan.md @@ -0,0 +1,105 @@ +# Architect Reactive State Management Refactor + +## Step 1: Harden Reactive Runtime +### Status Quo +- `src/state` primitives exist, but registry lifecycle is easy to forget and not wired by default. +- Tests cover basic get/set, but dependency-driven updates and rollback semantics are not fully validated or documented. +- Docs mention batching but do not explain lifecycle, rollback behavior, or threading assumptions. + +### Objectives +- Make the reactive runtime safe to use as a standalone module. +- Document lifecycle requirements and limitations. +- Add tests for dependency updates and rollback semantics. + +### Tech Notes +- Keep `state.init(allocator)` / `state.deinit()` as explicit calls. +- Add test coverage: + - `Computed` updates when dependencies change. + - `Effect` re-runs when dependencies change. + - `Transaction.rollback` discards pending notifications (does not restore values). +- Document threading assumptions (thread-local tracking) and rollback limitations. + +### Acceptance Criteria +- `state.init`/`state.deinit` documented and referenced in docs. +- Tests include dependency-driven recomputation and rollback notification discard. +- `zig build` and `zig build test` pass. + +## Step 2: Integrate AppStore (First Wiring Phase) +### Status Quo +- App state lives in `app_state.zig` with direct field access. +- UI and renderer rely on manual change propagation and `UiHost` snapshots. + +### Objectives +- Introduce `AppStore` with signals for view mode, focused session, animation state. +- Wire `state.init(...)` and `state.deinit()` into app startup/shutdown. +- Keep `UiHost` as compatibility layer while shifting reads to signals. + +### Tech Notes +- Create `src/app/app_store.zig` (or similar) with `Signal` fields. +- Replace direct reads in `main.zig` / app logic with signal accessors. +- Populate `UiHost` from AppStore signals without breaking existing UI flow. +- Add an `Effect` or subscription to set redraw flags when app signals change. + +### Acceptance Criteria +- `state.init`/`state.deinit` called exactly once in app lifecycle. +- `AppStore` exists and is the source of truth for view mode/focus/animation. +- No regressions in navigation or animation behavior. +- `UiHost` still functions but is populated from signals. + +## Step 3: Migrate Session State to SessionStore +### Status Quo +- Session state uses manual `dirty` flags and direct field access. +- Cache invalidation and render updates are manual. + +### Objectives +- Introduce `SessionStore` with signals for scroll, CWD, status, and any UI-facing fields. +- Remove reliance on `dirty` flag for invalidation. + +### Tech Notes +- Create a per-session store struct and keep it owned with session lifecycle. +- Replace `dirty` checks with signal subscriptions or computed invalidation. +- Ensure platform-specific CWD persistence logic remains intact. + +### Acceptance Criteria +- Session fields are signal-backed and invalidation is automatic. +- Manual `dirty` invalidation paths removed or narrowed to non-reactive data. +- No regressions in scroll behavior, focus switching, or status UI. + +## Step 4: Migrate UI Components to Reactive Access +### Status Quo +- UI components read from `UiHost` snapshots and manual flags. +- Frame requests use explicit `needsFrame()` logic. + +### Objectives +- Convert at least one UI component to read directly from signals/computeds. +- Use reactive effects to request frames when needed. + +### Tech Notes +- Pick a small component (toast, help overlay, or ESC indicator). +- Replace `UiHost` reads with signal accessors and/or computed values. +- Use `first_frame_guard` when visibility toggles to ensure immediate render. + +### Acceptance Criteria +- At least one component is fully reactive (no `UiHost` dependency). +- Frame requests are triggered by reactive effects where applicable. +- Component behavior matches previous UI. + +## Step 5: Cleanup and Optimization +### Status Quo +- Compatibility layers and sync logic will still exist after partial migration. +- Derived state is computed ad-hoc in multiple places. + +### Objectives +- Remove obsolete synchronization code and redundant snapshots. +- Consolidate common derived state into `Computed` values. +- Update documentation to match final architecture. + +### Tech Notes +- Remove now-unused `UiHost` fields once all components migrate. +- Add `Computed` helpers for common UI/renderer checks (e.g., `isAnimating`). +- Update `docs/architecture.md` and `docs/state_management_refactor.md`. + +### Acceptance Criteria +- No unused sync paths remain. +- Derived state has single source of truth via computeds. +- Docs accurately describe the reactive pipeline. diff --git a/src/state/computed.zig b/src/state/computed.zig new file mode 100644 index 0000000..c0e9274 --- /dev/null +++ b/src/state/computed.zig @@ -0,0 +1,281 @@ +// Computed: Derived reactive values that auto-update when dependencies change. +// +// A Computed wraps a function that derives a value from signals or other +// computeds. It automatically tracks which reactive values are accessed +// during computation and re-evaluates only when those dependencies change. +// +// Usage: +// var first_name = Signal([]const u8).init(allocator, "John"); +// var last_name = Signal([]const u8).init(allocator, "Doe"); +// +// var full_name = try Computed([]const u8).init(allocator, struct { +// fn compute() []const u8 { +// return first_name.get() ++ " " ++ last_name.get(); +// } +// }.compute, .{}); +// +// The computed will automatically re-evaluate when first_name or last_name changes. + +const std = @import("std"); +const tracker = @import("tracker.zig"); +const signal_mod = @import("signal.zig"); + +/// Lazily-evaluated derived value with automatic dependency tracking. +pub fn Computed(comptime T: type) type { + return struct { + const Self = @This(); + + allocator: std.mem.Allocator, + compute_fn: *const fn (*Self) T, + cached_value: ?T = null, + is_dirty: bool = true, + node_id: tracker.NodeId, + dependencies: []tracker.NodeId = &[_]tracker.NodeId{}, + subscribers: std.ArrayList(tracker.Subscription), + /// User context passed to compute function + context: ?*anyopaque = null, + + /// Initialize a computed with a derivation function. + pub fn init( + allocator: std.mem.Allocator, + compute_fn: *const fn (*Self) T, + context: ?*anyopaque, + ) Self { + return .{ + .allocator = allocator, + .compute_fn = compute_fn, + .node_id = signal_mod.generateNodeId(), + .context = context, + .subscribers = std.ArrayList(tracker.Subscription).init(allocator), + }; + } + + /// Clean up resources. + pub fn deinit(self: *Self) void { + // Unregister from all dependencies + self.unregisterFromDependencies(); + if (self.dependencies.len > 0) { + self.allocator.free(self.dependencies); + } + self.subscribers.deinit(self.allocator); + } + + fn unregisterFromDependencies(self: *Self) void { + const callback = struct { + fn markDirtyCallback(ctx: ?*anyopaque) void { + const computed: *Self = @ptrCast(@alignCast(ctx)); + computed.markDirty(); + } + }.markDirtyCallback; + + for (self.dependencies) |dep_id| { + tracker.unregisterObserver(dep_id, callback, self); + } + } + + fn registerWithDependencies(self: *Self) void { + const callback = struct { + fn markDirtyCallback(ctx: ?*anyopaque) void { + const computed: *Self = @ptrCast(@alignCast(ctx)); + computed.markDirty(); + } + }.markDirtyCallback; + + for (self.dependencies) |dep_id| { + tracker.registerObserver(dep_id, callback, self); + } + } + + /// Get the computed value, recomputing if necessary. + pub fn get(self: *Self) T { + tracker.recordAccess(self.node_id); + + if (self.is_dirty or self.cached_value == null) { + self.recompute(); + } + + return self.cached_value.?; + } + + /// Get without tracking (for debugging/logging). + pub fn peek(self: *Self) ?T { + return self.cached_value; + } + + /// Force recomputation on next access. + pub fn invalidate(self: *Self) void { + self.is_dirty = true; + } + + /// Subscribe to changes. + pub fn subscribe(self: *Self, callback: tracker.SubscriberFn, ctx: ?*anyopaque) !void { + try self.subscribers.append(self.allocator, .{ + .callback = callback, + .ctx = ctx, + }); + } + + /// Get the unique node ID. + pub fn getId(self: *const Self) tracker.NodeId { + return self.node_id; + } + + fn recompute(self: *Self) void { + // Unregister from old dependencies + self.unregisterFromDependencies(); + + // Free old dependencies + if (self.dependencies.len > 0) { + self.allocator.free(self.dependencies); + } + + // Track new dependencies + var tracking_ctx = tracker.TrackingContext.init(self.allocator); + defer tracking_ctx.deinit(); + + const previous_ctx = tracker.beginTracking(&tracking_ctx); + const new_value = self.compute_fn(self); + tracker.endTracking(previous_ctx); + + // Store dependencies + self.dependencies = tracking_ctx.consumeDependencies(); + + // Register with new dependencies + self.registerWithDependencies(); + + // Check if value changed + const changed = if (self.cached_value) |old| !std.meta.eql(old, new_value) else true; + + self.cached_value = new_value; + self.is_dirty = false; + + if (changed) { + self.notifySubscribers(); + } + } + + fn notifySubscribers(self: *Self) void { + for (self.subscribers.items) |sub| { + tracker.notify(sub.callback, sub.ctx); + } + } + + /// Mark as dirty when a dependency changes. + pub fn markDirty(self: *Self) void { + if (!self.is_dirty) { + self.is_dirty = true; + // Propagate to subscribers + self.notifySubscribers(); + } + } + }; +} + +/// Convenience wrapper for creating computeds with captured state. +pub fn ComputedWithContext(comptime T: type, comptime Context: type) type { + return struct { + const Self = @This(); + + inner: Computed(T), + ctx: *Context, + + pub fn init( + allocator: std.mem.Allocator, + compute_fn: *const fn (*Context) T, + ctx: *Context, + ) Self { + const wrapper = struct { + fn compute(computed: *Computed(T)) T { + const context: *Context = @ptrCast(@alignCast(computed.context)); + return compute_fn(context); + } + }; + + return .{ + .ctx = ctx, + .inner = Computed(T).init(allocator, wrapper.compute, ctx), + }; + } + + pub fn get(self: *Self) T { + return self.inner.get(); + } + + pub fn deinit(self: *Self) void { + self.inner.deinit(); + } + }; +} + +test "Computed basic derivation" { + var base = signal_mod.Signal(i32).init(std.testing.allocator, 10); + defer base.deinit(); + + const ComputeFn = struct { + var signal_ptr: *signal_mod.Signal(i32) = undefined; + + fn compute(_: *Computed(i32)) i32 { + return signal_ptr.get() * 2; + } + }; + ComputeFn.signal_ptr = &base; + + var doubled = Computed(i32).init(std.testing.allocator, ComputeFn.compute, null); + defer doubled.deinit(); + + try std.testing.expectEqual(@as(i32, 20), doubled.get()); + + base.set(21); + doubled.invalidate(); + try std.testing.expectEqual(@as(i32, 42), doubled.get()); +} + +test "Computed tracks dependencies" { + var a = signal_mod.Signal(i32).init(std.testing.allocator, 1); + defer a.deinit(); + + var b = signal_mod.Signal(i32).init(std.testing.allocator, 2); + defer b.deinit(); + + const ComputeFn = struct { + var a_ptr: *signal_mod.Signal(i32) = undefined; + var b_ptr: *signal_mod.Signal(i32) = undefined; + + fn compute(_: *Computed(i32)) i32 { + return a_ptr.get() + b_ptr.get(); + } + }; + ComputeFn.a_ptr = &a; + ComputeFn.b_ptr = &b; + + var sum = Computed(i32).init(std.testing.allocator, ComputeFn.compute, null); + defer sum.deinit(); + + try std.testing.expectEqual(@as(i32, 3), sum.get()); + try std.testing.expectEqual(@as(usize, 2), sum.dependencies.len); +} + +test "Computed updates when dependencies change" { + tracker.initRegistry(std.testing.allocator); + defer tracker.deinitRegistry(); + + var base = signal_mod.Signal(i32).init(std.testing.allocator, 2); + defer base.deinit(); + + const ComputeFn = struct { + var signal_ptr: *signal_mod.Signal(i32) = undefined; + + fn compute(_: *Computed(i32)) i32 { + return signal_ptr.get() * 3; + } + }; + ComputeFn.signal_ptr = &base; + + var triple = Computed(i32).init(std.testing.allocator, ComputeFn.compute, null); + defer triple.deinit(); + + try std.testing.expectEqual(@as(i32, 6), triple.get()); + + base.set(4); + try std.testing.expectEqual(@as(i32, 12), triple.get()); +} diff --git a/src/state/effect.zig b/src/state/effect.zig new file mode 100644 index 0000000..059dd95 --- /dev/null +++ b/src/state/effect.zig @@ -0,0 +1,307 @@ +// Effect: Side effects that run automatically when dependencies change. +// +// An Effect wraps a function that performs side effects (rendering, logging, +// network calls, etc.) and automatically re-runs when any tracked reactive +// values change. +// +// Usage: +// var count = Signal(i32).init(allocator, 0); +// +// var logger = try Effect.init(allocator, struct { +// fn run() void { +// std.debug.print("Count is now: {}\n", .{count.get()}); +// } +// }.run); +// defer logger.deinit(); +// +// The effect runs immediately and then again whenever count changes. + +const std = @import("std"); +const tracker = @import("tracker.zig"); +const signal_mod = @import("signal.zig"); + +/// Side effect that re-runs when tracked dependencies change. +pub const Effect = struct { + allocator: std.mem.Allocator, + effect_fn: *const fn (?*anyopaque) void, + context: ?*anyopaque, + dependencies: []tracker.NodeId = &[_]tracker.NodeId{}, + is_disposed: bool = false, + is_scheduled: bool = false, + + /// Initialize and immediately run the effect. + pub fn init( + allocator: std.mem.Allocator, + effect_fn: *const fn (?*anyopaque) void, + context: ?*anyopaque, + ) !Effect { + var self = Effect{ + .allocator = allocator, + .effect_fn = effect_fn, + .context = context, + }; + + // Run immediately to collect initial dependencies + self.run(); + + return self; + } + + /// Clean up the effect and stop reacting. + pub fn deinit(self: *Effect) void { + self.is_disposed = true; + self.unregisterFromDependencies(); + if (self.dependencies.len > 0) { + self.allocator.free(self.dependencies); + self.dependencies = &[_]tracker.NodeId{}; + } + } + + fn unregisterFromDependencies(self: *Effect) void { + const callback = struct { + fn scheduleCallback(ctx: ?*anyopaque) void { + const effect: *Effect = @ptrCast(@alignCast(ctx)); + effect.schedule(); + } + }.scheduleCallback; + + for (self.dependencies) |dep_id| { + tracker.unregisterObserver(dep_id, callback, self); + } + } + + fn registerWithDependencies(self: *Effect) void { + const callback = struct { + fn scheduleCallback(ctx: ?*anyopaque) void { + const effect: *Effect = @ptrCast(@alignCast(ctx)); + effect.schedule(); + } + }.scheduleCallback; + + for (self.dependencies) |dep_id| { + tracker.registerObserver(dep_id, callback, self); + } + } + + /// Manually trigger the effect to re-run. + pub fn run(self: *Effect) void { + if (self.is_disposed) return; + + // Unregister from old dependencies + self.unregisterFromDependencies(); + + // Free old dependencies + if (self.dependencies.len > 0) { + self.allocator.free(self.dependencies); + } + + // Track new dependencies + var tracking_ctx = tracker.TrackingContext.init(self.allocator); + defer tracking_ctx.deinit(); + + const previous_ctx = tracker.beginTracking(&tracking_ctx); + self.effect_fn(self.context); + tracker.endTracking(previous_ctx); + + // Store dependencies + self.dependencies = tracking_ctx.consumeDependencies(); + + // Register with new dependencies + self.registerWithDependencies(); + + self.is_scheduled = false; + } + + /// Schedule the effect to run (called when dependencies change). + pub fn schedule(self: *Effect) void { + if (self.is_disposed or self.is_scheduled) return; + self.is_scheduled = true; + + // In a real implementation, this would be queued for the next tick. + // For now, run immediately. + self.run(); + } + + /// Check if this effect depends on a given node. + pub fn dependsOn(self: *const Effect, node_id: tracker.NodeId) bool { + for (self.dependencies) |dep| { + if (dep == node_id) return true; + } + return false; + } +}; + +/// Creates an effect with a type-safe context. +pub fn EffectWithContext(comptime Context: type) type { + return struct { + const Self = @This(); + + inner: Effect, + ctx: *Context, + + pub fn init( + allocator: std.mem.Allocator, + effect_fn: *const fn (*Context) void, + ctx: *Context, + ) !Self { + const wrapper = struct { + fn run(context: ?*anyopaque) void { + const typed_ctx: *Context = @ptrCast(@alignCast(context)); + effect_fn(typed_ctx); + } + }; + + return .{ + .ctx = ctx, + .inner = try Effect.init(allocator, wrapper.run, ctx), + }; + } + + pub fn deinit(self: *Self) void { + self.inner.deinit(); + } + + pub fn run(self: *Self) void { + self.inner.run(); + } + }; +} + +/// Autorun: convenience wrapper that creates and manages an effect. +pub fn autorun( + allocator: std.mem.Allocator, + effect_fn: *const fn (?*anyopaque) void, + context: ?*anyopaque, +) !*Effect { + const effect = try allocator.create(Effect); + effect.* = try Effect.init(allocator, effect_fn, context); + return effect; +} + +/// Reaction: runs a side effect when a specific expression changes. +pub const Reaction = struct { + allocator: std.mem.Allocator, + data_fn: *const fn (?*anyopaque) ?*anyopaque, + effect_fn: *const fn (?*anyopaque, ?*anyopaque) void, + context: ?*anyopaque, + last_data: ?*anyopaque = null, + inner_effect: ?Effect = null, + is_disposed: bool = false, + + pub fn init( + allocator: std.mem.Allocator, + data_fn: *const fn (?*anyopaque) ?*anyopaque, + effect_fn: *const fn (?*anyopaque, ?*anyopaque) void, + context: ?*anyopaque, + ) Reaction { + return .{ + .allocator = allocator, + .data_fn = data_fn, + .effect_fn = effect_fn, + .context = context, + }; + } + + pub fn start(self: *Reaction) !void { + const wrapper = struct { + fn run(ctx: ?*anyopaque) void { + const reaction: *Reaction = @ptrCast(@alignCast(ctx)); + const new_data = reaction.data_fn(reaction.context); + + // Check if data changed (pointer comparison for simplicity) + if (new_data != reaction.last_data) { + reaction.effect_fn(new_data, reaction.context); + reaction.last_data = new_data; + } + } + }; + + self.inner_effect = try Effect.init(self.allocator, wrapper.run, self); + } + + pub fn deinit(self: *Reaction) void { + self.is_disposed = true; + if (self.inner_effect) |*effect| { + effect.deinit(); + } + } +}; + +test "Effect runs immediately" { + var run_count: u32 = 0; + const effect_fn = struct { + fn run(ctx: ?*anyopaque) void { + const count: *u32 = @ptrCast(@alignCast(ctx)); + count.* += 1; + } + }.run; + + var effect = try Effect.init(std.testing.allocator, effect_fn, &run_count); + defer effect.deinit(); + + try std.testing.expectEqual(@as(u32, 1), run_count); +} + +test "Effect can be manually re-run" { + var run_count: u32 = 0; + const effect_fn = struct { + fn run(ctx: ?*anyopaque) void { + const count: *u32 = @ptrCast(@alignCast(ctx)); + count.* += 1; + } + }.run; + + var effect = try Effect.init(std.testing.allocator, effect_fn, &run_count); + defer effect.deinit(); + + effect.run(); + effect.run(); + + try std.testing.expectEqual(@as(u32, 3), run_count); +} + +test "Effect stops after dispose" { + var run_count: u32 = 0; + const effect_fn = struct { + fn run(ctx: ?*anyopaque) void { + const count: *u32 = @ptrCast(@alignCast(ctx)); + count.* += 1; + } + }.run; + + var effect = try Effect.init(std.testing.allocator, effect_fn, &run_count); + + effect.deinit(); + effect.run(); // Should not run + + try std.testing.expectEqual(@as(u32, 1), run_count); +} + +test "Effect reruns when dependencies change" { + tracker.initRegistry(std.testing.allocator); + defer tracker.deinitRegistry(); + + var sig = signal_mod.Signal(i32).init(std.testing.allocator, 0); + defer sig.deinit(); + + var run_count: u32 = 0; + const EffectFn = struct { + var signal_ptr: *signal_mod.Signal(i32) = undefined; + + fn run(ctx: ?*anyopaque) void { + const count: *u32 = @ptrCast(@alignCast(ctx)); + _ = signal_ptr.get(); + count.* += 1; + } + }; + + EffectFn.signal_ptr = &sig; + + var effect = try Effect.init(std.testing.allocator, EffectFn.run, &run_count); + defer effect.deinit(); + + try std.testing.expectEqual(@as(u32, 1), run_count); + + sig.set(1); + try std.testing.expectEqual(@as(u32, 2), run_count); +} diff --git a/src/state/mod.zig b/src/state/mod.zig new file mode 100644 index 0000000..194e3f9 --- /dev/null +++ b/src/state/mod.zig @@ -0,0 +1,85 @@ +// State Management Module +// +// A MobX-inspired reactive state engine for Zig applications. +// Provides automatic dependency tracking, computed values, and +// side effects for building reactive UIs. +// +// Core Concepts: +// +// - Signal: Observable state that notifies on change +// - Computed: Derived values that auto-update when dependencies change +// - Effect: Side effects that run when dependencies change +// - Batch/Transaction: Group updates to minimize re-renders +// +// Example: +// +// const state = @import("state/mod.zig"); +// +// var count = state.Signal(i32).init(allocator, 0); +// defer count.deinit(); +// +// // Computed values auto-track dependencies +// var doubled = state.Computed(i32).init(allocator, struct { +// fn compute(_: *state.Computed(i32)) i32 { +// return count.get() * 2; +// } +// }.compute, null); +// defer doubled.deinit(); +// +// // Effects run when dependencies change +// var logger = try state.Effect.init(allocator, struct { +// fn run(_: ?*anyopaque) void { +// std.debug.print("Count: {}\n", .{count.get()}); +// } +// }.run, null); +// defer logger.deinit(); +// +// // Batch multiple updates +// state.tracker.beginBatch(allocator); +// count.set(1); +// count.set(2); +// count.set(3); +// state.tracker.endBatch(); // Effects run once here + +const std = @import("std"); +pub const tracker = @import("tracker.zig"); +pub const signal = @import("signal.zig"); +pub const computed = @import("computed.zig"); +pub const effect = @import("effect.zig"); +pub const store = @import("store.zig"); + +// Re-export commonly used types +pub const Signal = signal.Signal; +pub const Computed = computed.Computed; +pub const ComputedWithContext = computed.ComputedWithContext; +pub const Effect = effect.Effect; +pub const EffectWithContext = effect.EffectWithContext; +pub const Reaction = effect.Reaction; +pub const Transaction = store.Transaction; +pub const ObservableArray = store.ObservableArray; +pub const ObservableMap = store.ObservableMap; + +// Re-export utility functions +pub const runInAction = store.runInAction; +pub const runInActionWithContext = store.runInActionWithContext; +pub const autorun = effect.autorun; + +// Re-export tracker utilities +pub const beginBatch = tracker.beginBatch; +pub const endBatch = tracker.endBatch; +pub const untracked = tracker.untracked; +pub const isTracking = tracker.isTracking; + +/// Initialize the reactive system. Call once at startup with a long-lived allocator. +pub fn init(allocator: std.mem.Allocator) void { + tracker.initRegistry(allocator); +} + +/// Clean up global reactive state. Call on shutdown if init() was called. +pub fn deinit() void { + tracker.deinitRegistry(); +} + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/state/signal.zig b/src/state/signal.zig new file mode 100644 index 0000000..d6c1e77 --- /dev/null +++ b/src/state/signal.zig @@ -0,0 +1,191 @@ +// Signal: The core observable primitive for reactive state management. +// +// A Signal holds a value and notifies subscribers when it changes. +// Reading a signal's value automatically registers it as a dependency +// when inside a tracking context. +// +// Usage: +// var count = Signal(i32).init(allocator, 0); +// defer count.deinit(); +// +// const value = count.get(); // Tracks dependency if in reactive context +// count.set(42); // Notifies all subscribers + +const std = @import("std"); +const tracker = @import("tracker.zig"); + +var next_node_id: std.atomic.Value(tracker.NodeId) = std.atomic.Value(tracker.NodeId).init(1); + +/// Generate a unique node ID. Thread-safe. +pub fn generateNodeId() tracker.NodeId { + return next_node_id.fetchAdd(1, .seq_cst); +} + +/// Observable state container that notifies subscribers on change. +pub fn Signal(comptime T: type) type { + return struct { + const Self = @This(); + + allocator: std.mem.Allocator, + value: T, + node_id: tracker.NodeId, + subscribers: std.ArrayList(tracker.Subscription), + + /// Initialize a signal with an initial value. + pub fn init(allocator: std.mem.Allocator, initial_value: T) Self { + return .{ + .allocator = allocator, + .value = initial_value, + .node_id = generateNodeId(), + .subscribers = std.ArrayList(tracker.Subscription).init(allocator), + }; + } + + /// Clean up resources. + pub fn deinit(self: *Self) void { + self.subscribers.deinit(self.allocator); + } + + /// Get the current value. Registers as a dependency if tracking. + pub fn get(self: *const Self) T { + tracker.recordAccess(self.node_id); + return self.value; + } + + /// Get the current value without tracking (for use in effects/reactions). + pub fn peek(self: *const Self) T { + return self.value; + } + + /// Set a new value and notify subscribers if changed. + pub fn set(self: *Self, new_value: T) void { + if (comptime canCompareEquality(T)) { + if (std.meta.eql(self.value, new_value)) return; + } + self.value = new_value; + self.notifySubscribers(); + } + + /// Update the value using a function, useful for complex types. + pub fn update(self: *Self, updater: *const fn (T) T) void { + const new_value = updater(self.value); + self.set(new_value); + } + + /// Subscribe to changes. + pub fn subscribe(self: *Self, callback: tracker.SubscriberFn, ctx: ?*anyopaque) !void { + try self.subscribers.append(self.allocator, .{ + .callback = callback, + .ctx = ctx, + }); + } + + /// Unsubscribe from changes. + pub fn unsubscribe(self: *Self, callback: tracker.SubscriberFn, ctx: ?*anyopaque) void { + var i: usize = 0; + while (i < self.subscribers.items.len) { + const sub = self.subscribers.items[i]; + if (sub.callback == callback and sub.ctx == ctx) { + _ = self.subscribers.orderedRemove(i); + } else { + i += 1; + } + } + } + + /// Get the unique node ID for this signal. + pub fn getId(self: *const Self) tracker.NodeId { + return self.node_id; + } + + fn notifySubscribers(self: *Self) void { + for (self.subscribers.items) |sub| { + tracker.notify(sub.callback, sub.ctx); + } + // Also notify registered observers (computeds/effects) + tracker.notifyObservers(self.node_id); + } + + fn canCompareEquality(comptime U: type) bool { + return switch (@typeInfo(U)) { + .@"union" => |info| info.tag_type != null, + .@"opaque", .@"fn", .type, .noreturn => false, + else => true, + }; + } + }; +} + +/// Create a signal from an existing value (convenience function). +pub fn signal(allocator: std.mem.Allocator, value: anytype) Signal(@TypeOf(value)) { + return Signal(@TypeOf(value)).init(allocator, value); +} + +test "Signal basic get/set" { + var sig = Signal(i32).init(std.testing.allocator, 10); + defer sig.deinit(); + + try std.testing.expectEqual(@as(i32, 10), sig.get()); + + sig.set(42); + try std.testing.expectEqual(@as(i32, 42), sig.get()); +} + +test "Signal subscription notification" { + var sig = Signal(i32).init(std.testing.allocator, 0); + defer sig.deinit(); + + var notification_count: u32 = 0; + const callback = struct { + fn cb(ctx: ?*anyopaque) void { + const count: *u32 = @ptrCast(@alignCast(ctx)); + count.* += 1; + } + }.cb; + + try sig.subscribe(callback, ¬ification_count); + + sig.set(1); + try std.testing.expectEqual(@as(u32, 1), notification_count); + + sig.set(1); // Same value, should not notify + try std.testing.expectEqual(@as(u32, 1), notification_count); + + sig.set(2); + try std.testing.expectEqual(@as(u32, 2), notification_count); +} + +test "Signal dependency tracking" { + var sig = Signal(i32).init(std.testing.allocator, 100); + defer sig.deinit(); + + var ctx = tracker.TrackingContext.init(std.testing.allocator); + defer ctx.deinit(); + + _ = tracker.beginTracking(&ctx); + _ = sig.get(); + tracker.endTracking(null); + + const deps = ctx.consumeDependencies(); + defer std.testing.allocator.free(deps); + + try std.testing.expectEqual(@as(usize, 1), deps.len); + try std.testing.expectEqual(sig.getId(), deps[0]); +} + +test "Signal peek does not track" { + var sig = Signal(i32).init(std.testing.allocator, 100); + defer sig.deinit(); + + var ctx = tracker.TrackingContext.init(std.testing.allocator); + defer ctx.deinit(); + + _ = tracker.beginTracking(&ctx); + _ = sig.peek(); + tracker.endTracking(null); + + const deps = ctx.consumeDependencies(); + defer std.testing.allocator.free(deps); + + try std.testing.expectEqual(@as(usize, 0), deps.len); +} diff --git a/src/state/store.zig b/src/state/store.zig new file mode 100644 index 0000000..fcf9475 --- /dev/null +++ b/src/state/store.zig @@ -0,0 +1,337 @@ +// Store: Higher-level state container for organizing related reactive state. +// +// A Store groups related signals, computeds, and actions together, similar +// to MobX stores or Vuex modules. This provides better organization for +// complex state and enables features like snapshots and time-travel debugging. +// +// Usage: +// const CounterStore = Store(struct { +// count: Signal(i32), +// doubled: Computed(i32), +// +// pub fn increment(self: *@This()) void { +// self.count.set(self.count.get() + 1); +// } +// }); +// +// var store = CounterStore.init(allocator); +// store.state.increment(); + +const std = @import("std"); +const tracker = @import("tracker.zig"); +const signal_mod = @import("signal.zig"); + +/// Action decorator: wraps a mutation in a batch for efficient updates. +pub fn action( + allocator: std.mem.Allocator, + comptime func: anytype, +) @TypeOf(func) { + const Args = std.meta.ArgsTuple(@TypeOf(func)); + + return struct { + fn wrapped(args: Args) @typeInfo(@TypeOf(func)).@"fn".return_type.? { + tracker.beginBatch(allocator); + defer tracker.endBatch(); + return @call(.auto, func, args); + } + }.wrapped; +} + +/// Run a block of code as an action (batched updates). +pub fn runInAction(allocator: std.mem.Allocator, func: *const fn () void) void { + tracker.beginBatch(allocator); + defer tracker.endBatch(); + func(); +} + +/// Run a block with context as an action. +pub fn runInActionWithContext( + allocator: std.mem.Allocator, + comptime Context: type, + func: *const fn (*Context) void, + ctx: *Context, +) void { + tracker.beginBatch(allocator); + defer tracker.endBatch(); + func(ctx); +} + +/// Transaction: accumulates changes and applies them atomically. +pub const Transaction = struct { + allocator: std.mem.Allocator, + started: bool = false, + pending_len: usize = 0, + + pub fn init(allocator: std.mem.Allocator) Transaction { + return .{ .allocator = allocator }; + } + + pub fn begin(self: *Transaction) void { + if (!self.started) { + self.pending_len = tracker.pendingCount(); + tracker.beginBatch(self.allocator); + self.started = true; + } + } + + pub fn commit(self: *Transaction) void { + if (self.started) { + tracker.endBatch(); + self.started = false; + } + } + + pub fn rollback(self: *Transaction) void { + if (self.started) { + tracker.rollbackBatch(self.pending_len); + self.started = false; + } + } +}; + +/// Observable collection: array with reactive length and item access. +pub fn ObservableArray(comptime T: type) type { + return struct { + const Self = @This(); + + allocator: std.mem.Allocator, + items: std.ArrayList(T), + length_signal: signal_mod.Signal(usize), + node_id: tracker.NodeId, + subscribers: std.ArrayList(tracker.Subscription), + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ + .allocator = allocator, + .items = std.ArrayList(T).init(allocator), + .length_signal = signal_mod.Signal(usize).init(allocator, 0), + .node_id = signal_mod.generateNodeId(), + .subscribers = std.ArrayList(tracker.Subscription).init(allocator), + }; + } + + pub fn deinit(self: *Self) void { + self.items.deinit(self.allocator); + self.length_signal.deinit(); + self.subscribers.deinit(self.allocator); + } + + pub fn len(self: *Self) usize { + return self.length_signal.get(); + } + + pub fn get(self: *Self, index: usize) ?T { + tracker.recordAccess(self.node_id); + if (index >= self.items.items.len) return null; + return self.items.items[index]; + } + + pub fn set(self: *Self, index: usize, value: T) void { + if (index < self.items.items.len) { + self.items.items[index] = value; + self.notifySubscribers(); + } + } + + pub fn push(self: *Self, value: T) !void { + try self.items.append(self.allocator, value); + self.length_signal.set(self.items.items.len); + self.notifySubscribers(); + } + + pub fn pop(self: *Self) ?T { + if (self.items.items.len == 0) return null; + const value = self.items.pop(); + self.length_signal.set(self.items.items.len); + self.notifySubscribers(); + return value; + } + + pub fn clear(self: *Self) void { + self.items.clearRetainingCapacity(); + self.length_signal.set(0); + self.notifySubscribers(); + } + + pub fn slice(self: *Self) []const T { + tracker.recordAccess(self.node_id); + return self.items.items; + } + + pub fn subscribe(self: *Self, callback: tracker.SubscriberFn, ctx: ?*anyopaque) !void { + try self.subscribers.append(self.allocator, .{ + .callback = callback, + .ctx = ctx, + }); + } + + fn notifySubscribers(self: *Self) void { + for (self.subscribers.items) |sub| { + tracker.notify(sub.callback, sub.ctx); + } + tracker.notifyObservers(self.node_id); + } + }; +} + +/// Observable map: key-value store with reactive access. +pub fn ObservableMap(comptime K: type, comptime V: type) type { + return struct { + const Self = @This(); + + allocator: std.mem.Allocator, + map: std.AutoHashMap(K, V), + size_signal: signal_mod.Signal(usize), + node_id: tracker.NodeId, + subscribers: std.ArrayList(tracker.Subscription), + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ + .allocator = allocator, + .map = std.AutoHashMap(K, V).init(allocator), + .size_signal = signal_mod.Signal(usize).init(allocator, 0), + .node_id = signal_mod.generateNodeId(), + .subscribers = std.ArrayList(tracker.Subscription).init(allocator), + }; + } + + pub fn deinit(self: *Self) void { + self.map.deinit(); + self.size_signal.deinit(); + self.subscribers.deinit(self.allocator); + } + + pub fn size(self: *Self) usize { + return self.size_signal.get(); + } + + pub fn get(self: *Self, key: K) ?V { + tracker.recordAccess(self.node_id); + return self.map.get(key); + } + + pub fn put(self: *Self, key: K, value: V) !void { + const had_key = self.map.contains(key); + try self.map.put(key, value); + if (!had_key) { + self.size_signal.set(self.map.count()); + } + self.notifySubscribers(); + } + + pub fn remove(self: *Self, key: K) ?V { + const removed = self.map.fetchRemove(key); + if (removed) |kv| { + self.size_signal.set(self.map.count()); + self.notifySubscribers(); + return kv.value; + } + return null; + } + + pub fn contains(self: *Self, key: K) bool { + tracker.recordAccess(self.node_id); + return self.map.contains(key); + } + + pub fn subscribe(self: *Self, callback: tracker.SubscriberFn, ctx: ?*anyopaque) !void { + try self.subscribers.append(self.allocator, .{ + .callback = callback, + .ctx = ctx, + }); + } + + fn notifySubscribers(self: *Self) void { + for (self.subscribers.items) |sub| { + tracker.notify(sub.callback, sub.ctx); + } + tracker.notifyObservers(self.node_id); + } + }; +} + +test "ObservableArray basic operations" { + var arr = ObservableArray(i32).init(std.testing.allocator); + defer arr.deinit(); + + try arr.push(1); + try arr.push(2); + try arr.push(3); + + try std.testing.expectEqual(@as(usize, 3), arr.len()); + try std.testing.expectEqual(@as(?i32, 2), arr.get(1)); + + _ = arr.pop(); + try std.testing.expectEqual(@as(usize, 2), arr.len()); +} + +test "ObservableMap basic operations" { + var map = ObservableMap(u32, []const u8).init(std.testing.allocator); + defer map.deinit(); + + try map.put(1, "one"); + try map.put(2, "two"); + + try std.testing.expectEqual(@as(usize, 2), map.size()); + try std.testing.expectEqualStrings("one", map.get(1).?); + + _ = map.remove(1); + try std.testing.expectEqual(@as(usize, 1), map.size()); + try std.testing.expect(map.get(1) == null); +} + +test "Transaction batches updates" { + var notification_count: u32 = 0; + const callback = struct { + fn cb(ctx: ?*anyopaque) void { + const count: *u32 = @ptrCast(@alignCast(ctx)); + count.* += 1; + } + }.cb; + + var sig = signal_mod.Signal(i32).init(std.testing.allocator, 0); + defer sig.deinit(); + + try sig.subscribe(callback, ¬ification_count); + + var tx = Transaction.init(std.testing.allocator); + tx.begin(); + + sig.set(1); + sig.set(2); + sig.set(3); + + // No notifications yet + try std.testing.expectEqual(@as(u32, 0), notification_count); + + tx.commit(); + + // All notifications fired + try std.testing.expectEqual(@as(u32, 3), notification_count); +} + +test "Transaction rollback discards notifications" { + tracker.initRegistry(std.testing.allocator); + defer tracker.deinitRegistry(); + + var notification_count: u32 = 0; + const callback = struct { + fn cb(ctx: ?*anyopaque) void { + const count: *u32 = @ptrCast(@alignCast(ctx)); + count.* += 1; + } + }.cb; + + var sig = signal_mod.Signal(i32).init(std.testing.allocator, 0); + defer sig.deinit(); + + try sig.subscribe(callback, ¬ification_count); + + var tx = Transaction.init(std.testing.allocator); + tx.begin(); + sig.set(1); + + tx.rollback(); + + try std.testing.expectEqual(@as(u32, 0), notification_count); +} diff --git a/src/state/tracker.zig b/src/state/tracker.zig new file mode 100644 index 0000000..2076d66 --- /dev/null +++ b/src/state/tracker.zig @@ -0,0 +1,305 @@ +// Dependency tracker for reactive state management. +// +// The tracker maintains a global context that records which signals are +// accessed during a computation. This enables automatic dependency tracking +// similar to MobX's transparent reactivity model. +// +// Thread-local storage holds the current tracking context, allowing nested +// computations to properly track their own dependencies. + +const std = @import("std"); +const log = std.log.scoped(.state_tracker); + +/// Opaque handle identifying a reactive node (signal, computed, or effect). +pub const NodeId = u64; + +/// Subscriber callback type. Called when a dependency changes. +pub const SubscriberFn = *const fn (ctx: ?*anyopaque) void; + +/// Represents a subscription to a signal. +pub const Subscription = struct { + callback: SubscriberFn, + ctx: ?*anyopaque, +}; + +/// Global tracking context for automatic dependency collection. +/// When non-null, signal accesses are recorded as dependencies. +threadlocal var current_tracker: ?*TrackingContext = null; + +/// Batch depth counter. When > 0, notifications are deferred. +var batch_depth: u32 = 0; + +/// Pending notifications accumulated during batching. +var pending_notifications: std.ArrayList(PendingNotification) = undefined; +var pending_notifications_initialized: bool = false; +var pending_allocator: ?std.mem.Allocator = null; + +const PendingNotification = struct { + callback: SubscriberFn, + ctx: ?*anyopaque, +}; + +/// Global dependency registry: maps NodeId -> list of observers. +/// Used to notify computeds/effects when their dependencies change. +var dependency_registry: std.AutoHashMap(NodeId, std.ArrayList(Subscription)) = undefined; +var registry_initialized: bool = false; +var registry_allocator: ?std.mem.Allocator = null; + +/// Initialize the dependency registry (call once at startup with a long-lived allocator). +pub fn initRegistry(allocator: std.mem.Allocator) void { + if (!registry_initialized) { + dependency_registry = std.AutoHashMap(NodeId, std.ArrayList(Subscription)).init(allocator); + registry_allocator = allocator; + registry_initialized = true; + } +} + +/// Clean up the dependency registry. +pub fn deinitRegistry() void { + if (registry_initialized) { + var iter = dependency_registry.valueIterator(); + while (iter.next()) |list| { + if (registry_allocator) |alloc| { + list.deinit(alloc); + } + } + dependency_registry.deinit(); + registry_initialized = false; + registry_allocator = null; + } +} + +/// Register an observer for a node. Called when a computed/effect subscribes to a dependency. +pub fn registerObserver(node_id: NodeId, callback: SubscriberFn, ctx: ?*anyopaque) void { + if (!registry_initialized) return; + const alloc = registry_allocator orelse return; + + const entry = dependency_registry.getOrPut(node_id) catch |err| { + log.err("registerObserver getOrPut failed: {}", .{err}); + return; + }; + if (!entry.found_existing) { + entry.value_ptr.* = std.ArrayList(Subscription).init(alloc); + } + + // Avoid duplicate subscriptions + for (entry.value_ptr.items) |sub| { + if (sub.callback == callback and sub.ctx == ctx) return; + } + + entry.value_ptr.append(alloc, .{ .callback = callback, .ctx = ctx }) catch |err| { + log.err("registerObserver append failed: {}", .{err}); + }; +} + +/// Unregister an observer from a node. +pub fn unregisterObserver(node_id: NodeId, callback: SubscriberFn, ctx: ?*anyopaque) void { + if (!registry_initialized) return; + + if (dependency_registry.getPtr(node_id)) |list| { + var i: usize = 0; + while (i < list.items.len) { + const sub = list.items[i]; + if (sub.callback == callback and sub.ctx == ctx) { + _ = list.orderedRemove(i); + } else { + i += 1; + } + } + } +} + +/// Notify all registered observers of a node that it has changed. +pub fn notifyObservers(node_id: NodeId) void { + if (!registry_initialized) return; + + if (dependency_registry.get(node_id)) |list| { + for (list.items) |sub| { + notify(sub.callback, sub.ctx); + } + } +} + +/// Context for tracking dependencies during a computation. +pub const TrackingContext = struct { + allocator: std.mem.Allocator, + dependencies: std.ArrayList(NodeId), + parent: ?*TrackingContext = null, + + pub fn init(allocator: std.mem.Allocator) TrackingContext { + return .{ + .allocator = allocator, + .dependencies = std.ArrayList(NodeId).init(allocator), + }; + } + + pub fn deinit(self: *TrackingContext) void { + self.dependencies.deinit(self.allocator); + } + + /// Record a dependency on the given node. + pub fn track(self: *TrackingContext, node_id: NodeId) void { + // Avoid duplicate tracking + for (self.dependencies.items) |id| { + if (id == node_id) return; + } + self.dependencies.append(self.allocator, node_id) catch |err| { + log.err("track append failed: {}", .{err}); + }; + } + + /// Get collected dependencies and clear the list. + pub fn consumeDependencies(self: *TrackingContext) []NodeId { + const deps = self.dependencies.toOwnedSlice(self.allocator) catch |err| { + log.err("consumeDependencies failed: {}", .{err}); + return &[_]NodeId{}; + }; + return deps; + } +}; + +/// Begin tracking dependencies. Returns the previous context for restoration. +pub fn beginTracking(ctx: *TrackingContext) ?*TrackingContext { + const parent = current_tracker; + ctx.parent = parent; + current_tracker = ctx; + return parent; +} + +/// End tracking and restore the previous context. +pub fn endTracking(previous: ?*TrackingContext) void { + current_tracker = previous; +} + +/// Record access to a node. Called by signals when their value is read. +pub fn recordAccess(node_id: NodeId) void { + if (current_tracker) |ctx| { + ctx.track(node_id); + } +} + +/// Check if we're currently tracking dependencies. +pub fn isTracking() bool { + return current_tracker != null; +} + +/// Begin a batch update. Notifications are deferred until the batch ends. +pub fn beginBatch(allocator: std.mem.Allocator) void { + if (batch_depth == 0) { + pending_allocator = allocator; + pending_notifications = std.ArrayList(PendingNotification).init(allocator); + pending_notifications_initialized = true; + } + batch_depth += 1; +} + +/// End a batch update. If this is the outermost batch, flush pending notifications. +pub fn endBatch() void { + if (batch_depth == 0) return; + batch_depth -= 1; + + if (batch_depth == 0 and pending_notifications_initialized) { + // Flush all pending notifications + const items = pending_notifications.items; + for (items) |notification| { + notification.callback(notification.ctx); + } + if (pending_allocator) |alloc| { + pending_notifications.deinit(alloc); + } + pending_notifications_initialized = false; + pending_allocator = null; + } +} + +/// Get the number of pending notifications. +pub fn pendingCount() usize { + if (!pending_notifications_initialized) return 0; + return pending_notifications.items.len; +} + +/// Roll back a batch, discarding pending notifications appended after pending_len. +pub fn rollbackBatch(pending_len: usize) void { + if (batch_depth == 0) return; + + if (pending_notifications_initialized) { + if (pending_len < pending_notifications.items.len) { + pending_notifications.shrinkRetainingCapacity(pending_len); + } + } + + batch_depth -= 1; + if (batch_depth == 0 and pending_notifications_initialized) { + if (pending_allocator) |alloc| { + pending_notifications.deinit(alloc); + } + pending_notifications_initialized = false; + pending_allocator = null; + } +} + +/// Check if we're inside a batch. +pub fn isBatching() bool { + return batch_depth > 0; +} + +/// Queue a notification for later (if batching) or execute immediately. +pub fn notify(callback: SubscriberFn, ctx: ?*anyopaque) void { + if (batch_depth > 0 and pending_notifications_initialized) { + if (pending_allocator) |alloc| { + pending_notifications.append(alloc, .{ + .callback = callback, + .ctx = ctx, + }) catch |err| { + log.err("notify append failed: {}", .{err}); + }; + } + } else { + callback(ctx); + } +} + +/// Execute a function with dependency tracking disabled. +pub fn untracked(comptime func: anytype, args: anytype) @TypeOf(@call(.auto, func, args)) { + const saved = current_tracker; + current_tracker = null; + defer current_tracker = saved; + return @call(.auto, func, args); +} + +test "TrackingContext basic tracking" { + var ctx = TrackingContext.init(std.testing.allocator); + defer ctx.deinit(); + + _ = beginTracking(&ctx); + defer endTracking(null); + + recordAccess(1); + recordAccess(2); + recordAccess(1); // duplicate + + const deps = ctx.consumeDependencies(); + defer std.testing.allocator.free(deps); + + try std.testing.expectEqual(@as(usize, 2), deps.len); + try std.testing.expectEqual(@as(NodeId, 1), deps[0]); + try std.testing.expectEqual(@as(NodeId, 2), deps[1]); +} + +test "batch notifications" { + var call_count: u32 = 0; + const callback = struct { + fn cb(ctx: ?*anyopaque) void { + const count: *u32 = @ptrCast(@alignCast(ctx)); + count.* += 1; + } + }.cb; + + beginBatch(std.testing.allocator); + notify(callback, &call_count); + notify(callback, &call_count); + try std.testing.expectEqual(@as(u32, 0), call_count); + + endBatch(); + try std.testing.expectEqual(@as(u32, 2), call_count); +}