Snapshots
Snapshots are the read-only view of session state that gets pushed to Dart. Each modality defines its own concrete snapshot type as an associated type on Modality. There is no generic SessionSnapshot<S, E> — snapshots are per-modality DTOs designed for the specific needs of each modality’s UI.
Associated Type on Modality
Section titled “Associated Type on Modality”trait Modality: Reducer<...> + Component { // ... type Snapshot: Clone + Send + Sync + 'static;
fn snapshot( &self, ai: &AiState, export: &ExportState, version: u64, can_undo: bool, can_redo: bool, ) -> Self::Snapshot;}The snapshot() method builds a snapshot from references to the current state. It does not clone the entire state — it selectively extracts what the UI needs. This is what allows Ephemeral to omit the Clone bound (important for modalities like LessonPlan whose ephemeral state contains non-clonable children like Session<Whiteboard>).
Snapshot Arguments
Section titled “Snapshot Arguments”| Argument | Source | Purpose |
|---|---|---|
&self | Session.modality | Domain state: synced + ephemeral |
ai: &AiState | Session.ephemeral.ai | Streaming indicator, conversation ID |
export: &ExportState | Session.ephemeral.export | Export bytes, readiness, errors |
version: u64 | Session.version | Monotonic counter for change detection |
can_undo: bool | undo_timeline.can_undo() || doc.can_undo() | Whether undo is available |
can_redo: bool | undo_timeline.can_redo() || doc.can_redo() | Whether redo is available |
AI and export state live on SessionEphemeral, not on the modality. Undo availability is computed by Session by OR-ing the UndoTimeline (parent session interleaved tracking) and Doc (leaf session LoroDoc undo). Session passes all of these as arguments so the snapshot can include them without the modality needing to know about session-level concerns.
Per-Modality Snapshot DTOs
Section titled “Per-Modality Snapshot DTOs”Each modality defines a concrete struct:
| Modality | Snapshot Type | Key Fields |
|---|---|---|
| Whiteboard | WhiteboardSnapshot | id, name, schema, placements, elements, selected_ids, tool settings, canvas settings, focused_prose, ai, version, can_undo, can_redo |
| Worksheet | WorksheetSnapshot | elements, placements, grid_state, ai, export, version, can_undo, can_redo |
| LessonPlan | LessonPlanSnapshot | slides, selected_slide_id, child_snapshots, ai, export, version, can_undo, can_redo |
These are #[frb(non_opaque)] DTOs — FRB generates them as Dart classes with public fields. Dart renders directly from snapshot data.
Typed Sinks
Section titled “Typed Sinks”Sinks are typed per-modality:
// On Session:sinks: Vec<Box<dyn Sink<M::Snapshot>>>The Sink trait:
pub trait Sink<T>: Send + Sync { fn push(&self, value: T) -> bool;}push() returns bool — true to keep the sink, false to remove it. Session uses sinks.retain(|sink| sink.push(snapshot.clone())) so closed sinks are automatically cleaned up. Each sink receives the modality’s concrete snapshot type. The FRB API layer registers a sink that forwards snapshots to Dart via a stream. Multiple sinks can be registered (e.g., one for the main UI, one for a preview panel).
When Snapshots Emit
Section titled “When Snapshots Emit”Snapshots are emitted in step 4 of the dispatch lifecycle, after all reduces have completed and the state has been recompiled and committed:
// Inside Session::dispatch(), step 5:self.recompile();let synced_changed = self.commit_if_changed();self.rt.state.version += 1;self.emit(); // <-- snapshot built and pushed hereif synced_changed { self.fire_on_change(); }The emit() method:
- Computes
can_undo/can_redofromundo_timelineanddoc. - Calls
self.modality.snapshot(&ai, &export, version, can_undo, can_redo). - Pushes the snapshot to all sinks via
sinks.retain(|sink| sink.push(snapshot.clone()))— sinks that returnfalseare removed.
Every dispatch() call emits a snapshot, even if nothing changed. The version counter lets consumers detect no-ops cheaply.
Snapshot Flow
Section titled “Snapshot Flow”sequenceDiagram participant Dispatch as Session::dispatch() participant Modality as M::snapshot() participant Sink1 as Sink (FRB Stream) participant Sink2 as Sink (Preview) participant Dart
Dispatch->>Dispatch: can_undo = timeline.can_undo() || doc.can_undo() Dispatch->>Modality: snapshot(&ai, &export, version, can_undo, can_redo) Modality-->>Dispatch: M::Snapshot Dispatch->>Sink1: push(snapshot.clone()) → bool Dispatch->>Sink2: push(snapshot.clone()) → bool Sink1->>Dart: Stream eventBuilding Snapshots Without Cloning State
Section titled “Building Snapshots Without Cloning State”The snapshot() method receives &self — an immutable reference to the modality. It reads from synced and ephemeral state to build the DTO:
impl Modality for Whiteboard { fn snapshot( &self, ai: &AiState, _export: &ExportState, version: u64, can_undo: bool, can_redo: bool, ) -> WhiteboardSnapshot { WhiteboardSnapshot { id: self.rt.state.synced.id.clone(), name: self.rt.state.synced.name.clone(), description: self.rt.state.synced.description.clone(), schema: self.rt.state.synced.schema.clone(), placements: /* convert placements */, elements: self.rt.state.ephemeral.output.clone(), selected_ids: self.rt.state.ephemeral.selected_ids.clone(), current_tool: self.rt.state.ephemeral.current_tool, // ... tool_color, tool_thickness, tool_shape_type, tool_font_family, // ... canvas_zoom, canvas_pan_x, canvas_pan_y, canvas_width, canvas_height focused_prose: /* from focused_text_id + prose_editors */, ai: ai.clone(), version, can_undo, can_redo, } }}Only the fields needed by the UI are cloned. Large state that the UI does not need (e.g., prose editor internals, internal caches) is excluded. The can_undo/can_redo flags are passed through from Session’s undo computation.
Related Pages
Section titled “Related Pages”- Session Overview — where sinks and SessionEphemeral live on the Session struct
- Dispatch Lifecycle — when emit() runs in the dispatch flow
- Persistence — commit_if_changed() runs before emit()